@cantera/model-viewer-pagetemplate
A complete APS project-tree and Autodesk Viewer page: scoped Autodesk sign-in, lazy 3-legged Data Management browsing, recursive project and folder search, a full-bleed native viewer, account controls, and untranslated-model states.
npx shadcn@latest add @cantera/model-viewer-pageInstalled at /models: the page, its loading UI, the lazy /api/models/tree route, and the two-legged /api/viewer-token route. ModelBrowser is the screen; signed out, /models renders ScopedAutodeskSignIn inline.
Next: 1. Fill the keys acc-auth-routes added to .env.local (see its notes above). 2. Run next dev and open /models.
The cantera showcase emulator supplies a project tree but no geometry; store real derivative URNs on your item versions. Reference: https://canteraui.vercel.app/components/model-viewer-page
ModelBrowser fetches from treeEndpoint (default /api/models/tree) and viewerTokenEndpoint (default /api/viewer-token), the routes this template ships. Point both at your own handlers to mount the screen elsewhere. initialNodes skips the first hub read when the server already has the tree; embedded drops the page chrome so the browser fits a docs or preview frame.
| Prop | Type | Default | Description |
|---|---|---|---|
| account | OAuthAccount | — | Signed-in account shown in the fixed top-right account control. |
| initialNodes | HubTreeNode[] | [] | Optional normalized roots. When empty, the client starts by requesting hubs from treeEndpoint. |
| treeEndpoint | string | '/api/models/tree' | Session-backed lazy Data Management route implementing the shared kind and id query contract. |
| viewerTokenEndpoint | string | '/api/viewer-token' | Separate two-legged viewer token route, scoped to viewables:read. Three-legged Data Management tokens never cross into the viewer. |
| signOutHref | string | '/api/auth/signout?next=/sign-in' | POST route used by the account control to revoke the grant and clear the session. |
| embedded | boolean | false | Constrains the desktop sidebar and shell height to the nearest positioned preview container. Leave false for the full-page route. |
| Query | Type | Description |
|---|---|---|
| kind=hubs | no ids | Loads the signed-in account’s hubs. |
| kind=projects | hubId | Loads projects under one hub. |
| kind=top-folders | hubId, projectId | Loads a project’s top folders. |
| kind=folder-contents | projectId, folderId | Loads folders and items under one folder. |
| kind=versions | projectId, itemId | Loads immutable versions under one item. |
| kind=search | projectId, folderId, q | Recursively searches unopened descendants of one folder and returns matching tip versions for the scoped finder. |
| kind=path | projectId, itemId, topFolderId | Walks a found item’s parent folders up to the searched top folder and returns the intermediate segments, so picking a search result can expand and select it in the tree. |
This is the exact code the CLI installs into your project — you own it from there.
import { cookies } from 'next/headers'
import { ModelBrowser } from '@/components/model-browser'
import { ScopedAutodeskSignIn } from '@/components/scoped-autodesk-sign-in'
import { openSession, SESSION_COOKIE } from '@/lib/acc-auth'
export default async function ModelViewerPage() {
const cookieStore = await cookies()
const session = await openSession(cookieStore.get(SESSION_COOKIE)?.value)
if (!session) {
return (
<main className="flex min-h-svh items-center justify-center p-6">
<ScopedAutodeskSignIn
nextPath="/models"
title="Browse models"
description="Connect your Autodesk account to browse project models."
/>
</main>
)
}
return (
<ModelBrowser
account={{ name: session.name, email: session.email, avatarUrl: session.avatarUrl }}
/>
)
}
import { LoaderCircleIcon } from 'lucide-react'
export default function ModelViewerLoading() {
return (
<main className="grid h-svh min-h-[32rem] grid-cols-[20rem_minmax(0,1fr)] overflow-hidden bg-background">
<aside className="flex min-w-0 flex-col border-r bg-background shadow-sm">
<header className="flex min-h-16 items-center border-b px-4">
<div>
<h1 className="font-heading font-medium text-lg">Models</h1>
<p className="text-muted-foreground text-xs">Autodesk project files</p>
</div>
</header>
<output className="flex min-h-11 items-center gap-2 px-5 text-muted-foreground text-xs">
<span aria-hidden className="grid size-3.5 animate-spin place-items-center">
<LoaderCircleIcon className="size-3.5" />
</span>
Checking your Autodesk connection
</output>
</aside>
<section className="grid place-items-center bg-muted" aria-label="Loading model viewer">
<div aria-hidden className="h-24 w-72 rounded-xl bg-background shadow-sm" />
</section>
</main>
)
}
'use client'
import { Building2Icon, LoaderCircleIcon, LogOutIcon, TriangleAlertIcon } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { APSViewer } from '@/components/ui/aps-viewer/aps-viewer'
import { Button } from '@/components/ui/button'
import type { FinderEntry } from '@/components/ui/finder'
import { HubSidebar } from '@/components/ui/hub-sidebar'
import type { HubTreeBranchNode, HubTreeNode, HubTreeVersionNode } from '@/components/ui/hub-tree'
import { ModelStatusCard } from '@/components/ui/model-status-card'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { statusInkClasses, statusToneClasses } from '@/components/ui/token-status'
import { UserAccountBadge } from '@/components/ui/user-account-badge'
import type { OAuthAccount } from '@/lib/oauth-types'
import type { BrowsePathSegment, Item, ItemVersion } from '@/lib/project-types'
import { cn } from '@/lib/utils'
import { AEC_STARTER_EXTENSIONS } from '@/lib/viewer-extension-types'
import type { GetAccessToken } from '@/lib/viewer-types'
import { useModelFinder } from './model-finder'
interface TreeResponse {
nodes?: HubTreeNode[]
error?: string
}
export interface ModelBrowserProps {
account: OAuthAccount
initialNodes?: HubTreeNode[]
treeEndpoint?: string
viewerTokenEndpoint?: string
signOutHref?: string
embedded?: boolean
}
interface TreeRequest {
kind: 'projects' | 'top-folders' | 'folder-contents' | 'versions'
hubId?: string
projectId?: string
folderId?: string
itemId?: string
}
interface PathResponse {
segments?: BrowsePathSegment[]
error?: string
}
interface TreeIssue {
message: string
/** The session can no longer reach Autodesk — offer the reconnect action. */
reconnect: boolean
}
interface ViewerIssue {
kind: 'unviewable' | 'error'
detail: string
}
class TreeRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
}
}
function treeIssueFor(error: unknown, fallback: string): TreeIssue {
if (error instanceof TreeRequestError) {
return { message: error.message, reconnect: error.status === 401 }
}
return { message: error instanceof Error ? error.message : fallback, reconnect: false }
}
function viewerIssueFor(error: Error): ViewerIssue {
// Document.load code 5 is "file not found" and a loaded manifest can still
// carry no default geometry: both mean the version has nothing viewable,
// which is an expected state for untranslated formats.
const unviewable =
/Document\.load failed \(5\)/.test(error.message) ||
/\b404\b/.test(error.message) ||
error.message.includes('document has no viewable geometry')
return { kind: unviewable ? 'unviewable' : 'error', detail: error.message }
}
function replaceChildren(
nodes: HubTreeNode[],
parentId: string,
children: HubTreeNode[],
): HubTreeNode[] {
return nodes.map((node) => {
if (node.id === parentId) {
if (node.type === 'item') {
return {
...node,
children: children.filter(
(child): child is HubTreeVersionNode => child.type === 'version',
),
hasChildren: children.some((child) => child.type === 'version'),
}
}
if (node.type === 'version') return node
return { ...node, children, hasChildren: children.length > 0 }
}
if (node.type === 'item' || node.type === 'version') return node
if (!node.children?.length) return node
const next = replaceChildren(node.children, parentId, children)
return next === node.children ? node : { ...node, children: next }
})
}
function findPath(nodes: HubTreeNode[], targetId: string): HubTreeNode[] | undefined {
for (const node of nodes) {
if (node.id === targetId) return [node]
const descendants = node.children && findPath(node.children, targetId)
if (descendants) return [node, ...descendants]
}
return undefined
}
function requestFor(node: HubTreeBranchNode, path: HubTreeNode[]): TreeRequest {
const hub = path.find((entry) => entry.type === 'hub')
const project = path.find((entry) => entry.type === 'project')
if (node.type === 'hub') return { kind: 'projects', hubId: node.value.id }
if (node.type === 'project') {
return {
kind: 'top-folders',
hubId: node.value.hubId ?? (hub?.type === 'hub' ? hub.value.id : undefined),
projectId: node.value.id,
}
}
if (node.type === 'folder') {
return {
kind: 'folder-contents',
projectId: project?.type === 'project' ? project.value.id : undefined,
folderId: node.value.id,
}
}
return {
kind: 'versions',
projectId: project?.type === 'project' ? project.value.id : undefined,
itemId: node.value.id,
}
}
function selectedTreeId(
nodes: HubTreeNode[],
item: Item,
version?: ItemVersion,
): string | undefined {
for (const node of nodes) {
if (version && node.type === 'version' && node.value.id === version.id) return node.id
if (!version && node.type === 'item' && node.value.id === item.id) return node.id
const nested = node.children && selectedTreeId(node.children, item, version)
if (nested) return nested
}
return undefined
}
function paramsFor(request: TreeRequest): URLSearchParams {
const params = new URLSearchParams({ kind: request.kind })
if (request.hubId) params.set('hubId', request.hubId)
if (request.projectId) params.set('projectId', request.projectId)
if (request.folderId) params.set('folderId', request.folderId)
if (request.itemId) params.set('itemId', request.itemId)
return params
}
function ModelBrowser({
account,
initialNodes = [],
treeEndpoint = '/api/models/tree',
viewerTokenEndpoint = '/api/viewer-token',
signOutHref = '/api/auth/signout?next=/sign-in',
embedded = false,
}: ModelBrowserProps) {
const [nodes, setNodes] = useState(initialNodes)
const [expandedIds, setExpandedIds] = useState<string[]>([])
const [selectedId, setSelectedId] = useState<string>()
const [pendingIds, setPendingIds] = useState<string[]>([])
const [rootPending, setRootPending] = useState(initialNodes.length === 0)
const [treeIssue, setTreeIssue] = useState<TreeIssue>()
const [selection, setSelection] = useState<{ item: Item; version?: ItemVersion }>()
const [viewerIssue, setViewerIssue] = useState<ViewerIssue>()
const finder = useModelFinder({ nodes, treeEndpoint })
// Callers set rootPending before invoking; the first load relies on its
// initializer so the effect never writes state synchronously.
const loadRoots = useCallback(
(signal?: AbortSignal) =>
fetch(`${treeEndpoint}?kind=hubs`, { cache: 'no-store', signal })
.then(async (response) => {
const body = (await response.json()) as TreeResponse
if (!response.ok || !body.nodes) {
throw new TreeRequestError(body.error ?? 'Hubs could not be loaded.', response.status)
}
setNodes(body.nodes)
setTreeIssue(undefined)
})
.catch((error) => {
if (!signal?.aborted) setTreeIssue(treeIssueFor(error, 'Hubs could not be loaded.'))
})
.finally(() => {
if (!signal?.aborted) setRootPending(false)
}),
[treeEndpoint],
)
useEffect(() => {
if (initialNodes.length > 0) return
const controller = new AbortController()
void loadRoots(controller.signal)
return () => controller.abort()
}, [initialNodes.length, loadRoots])
const getAccessToken = useCallback<GetAccessToken>(async () => {
const response = await fetch(viewerTokenEndpoint, { cache: 'no-store' })
if (!response.ok) throw new Error('The viewer token is unavailable.')
return (await response.json()) as Awaited<ReturnType<GetAccessToken>>
}, [viewerTokenEndpoint])
async function loadChildren(
node: HubTreeBranchNode,
path: HubTreeNode[],
): Promise<HubTreeNode[]> {
const response = await fetch(`${treeEndpoint}?${paramsFor(requestFor(node, path))}`, {
cache: 'no-store',
})
const body = (await response.json()) as TreeResponse
if (!response.ok || !body.nodes) {
throw new TreeRequestError(body.error ?? `${node.name} could not be loaded.`, response.status)
}
setNodes((current) => replaceChildren(current, node.id, body.nodes ?? []))
return body.nodes
}
async function expand(node: HubTreeBranchNode): Promise<void> {
const path = findPath(nodes, node.id)
if (!path) return
if (node.children !== undefined) {
finder.updateScope(node, path, node.children)
setExpandedIds((current) => (current.includes(node.id) ? current : [...current, node.id]))
return
}
finder.updateScope(node, path, undefined)
setPendingIds((current) => (current.includes(node.id) ? current : [...current, node.id]))
setTreeIssue(undefined)
try {
const children = await loadChildren(node, path)
finder.updateScope(node, path, children)
setExpandedIds((current) => (current.includes(node.id) ? current : [...current, node.id]))
} catch (error) {
setTreeIssue(treeIssueFor(error, `${node.name} could not be loaded.`))
} finally {
setPendingIds((current) => current.filter((id) => id !== node.id))
}
}
async function revealEntry(entry: FinderEntry): Promise<void> {
if (!entry.path?.length) return
let working = nodes
const expandIds: string[] = []
let ancestors: HubTreeNode[] = []
let level: HubTreeNode[] = working
let segments = [...entry.path]
try {
for (let index = 0; index < segments.length; index += 1) {
const seg = segments[index]
const node = level.find(
(candidate) => candidate.type === seg?.type && candidate.value.id === seg.id,
)
if (!seg || !node || node.type === 'item' || node.type === 'version') break
expandIds.push(node.id)
const children = node.children ?? (await loadChildren(node, [...ancestors, node]))
working = replaceChildren(working, node.id, children)
ancestors = [...ancestors, node]
level = children
const last = index === segments.length - 1
const found = children.some(
(child) => child.type === 'item' && child.value.id === entry.item.id,
)
if (last && !found && seg.type === 'folder') {
const project = segments.find((candidate) => candidate.type === 'project')
if (!project || segments.length > entry.path.length) break
const params = new URLSearchParams({
kind: 'path',
projectId: project.id,
itemId: entry.item.id,
topFolderId: seg.id,
})
const response = await fetch(`${treeEndpoint}?${params}`, { cache: 'no-store' })
const body = (await response.json()) as PathResponse
if (!response.ok || !body.segments) break
segments = [...segments, ...body.segments]
}
}
} catch {
// Best-effort: the entry already opened; reveal as far as the tree loaded.
}
const versionRowId = entry.version
? selectedTreeId(working, entry.item, entry.version)
: undefined
const itemRowId = selectedTreeId(working, entry.item)
if (versionRowId && itemRowId) expandIds.push(itemRowId)
setExpandedIds((current) => [...current, ...expandIds.filter((id) => !current.includes(id))])
setSelectedId(versionRowId ?? itemRowId)
}
function openItem(item: Item, version?: ItemVersion): void {
setSelection({ item, version })
setSelectedId(selectedTreeId(nodes, item, version))
setViewerIssue(undefined)
}
async function openFinderEntry(entry: FinderEntry): Promise<void> {
setSelection({ item: entry.item, version: entry.version })
setViewerIssue(undefined)
await revealEntry(entry)
}
const urn = selection
? ((selection.version ? selection.version.derivativeUrn : selection.item.tip?.derivativeUrn) ??
undefined)
: undefined
const selectionLabel = selection
? selection.version
? `${selection.item.name} · v${selection.version.versionNumber}`
: selection.item.name
: undefined
const reconnectAction = (
<form action={signOutHref} method="post">
<Button
type="submit"
variant="outline"
size="sm"
className="relative after:absolute after:-inset-y-2 after:inset-x-0"
>
Reconnect Autodesk
</Button>
</form>
)
const treeEmpty = rootPending ? (
<output className="flex min-h-11 items-center justify-center gap-2 px-2 py-6 text-muted-foreground text-xs">
<LoaderCircleIcon aria-hidden className="size-3.5 animate-spin" />
Loading hubs
</output>
) : treeIssue ? (
<div role="alert" className="flex flex-col items-center gap-3 px-3 py-8 text-center">
<span
className={cn(
'grid size-8 shrink-0 place-items-center rounded-full',
statusToneClasses.warning,
)}
>
<TriangleAlertIcon aria-hidden className="size-4" />
</span>
<p className="text-sm">{treeIssue.message}</p>
{treeIssue.reconnect ? (
reconnectAction
) : (
<Button
variant="outline"
size="sm"
className="relative after:absolute after:-inset-y-2 after:inset-x-0"
onClick={() => {
setRootPending(true)
void loadRoots()
}}
>
Retry
</Button>
)}
</div>
) : undefined
const treeAlert =
nodes.length > 0 && treeIssue ? (
<div
role="alert"
className="flex flex-col gap-2 rounded-lg border border-border p-2.5 text-xs group-data-[collapsible=icon]:hidden"
>
<span className="flex items-start gap-1.5">
<TriangleAlertIcon
aria-hidden
className={cn('mt-0.5 size-3.5 shrink-0', statusInkClasses.warning)}
/>
<span>{treeIssue.message}</span>
</span>
{treeIssue.reconnect && reconnectAction}
</div>
) : undefined
return (
<SidebarProvider
className={
embedded
? 'relative h-[36rem] min-h-[36rem] overflow-hidden bg-background'
: 'h-svh min-h-[32rem] overflow-hidden bg-background'
}
>
<HubSidebar
finder={{
query: finder.query,
onQueryChange: finder.setQuery,
groups: finder.groups,
onItemOpen: openFinderEntry,
onReveal: (entry) => {
void revealEntry(entry)
},
placeholder: finder.placeholder,
emptyLabel: finder.emptyLabel,
scope: finder.scope?.label,
}}
tree={{
nodes,
expandedIds,
selectedId,
pendingIds,
empty: treeEmpty,
onExpand: expand,
onCollapse: (node) => setExpandedIds((current) => current.filter((id) => id !== node.id)),
onItemOpen: openItem,
}}
header={treeAlert}
collapsible="icon"
className={
embedded ? 'border-border border-r md:absolute md:h-full' : 'border-border border-r'
}
/>
<SidebarInset
className={
embedded
? 'h-full min-h-0 min-w-0 overflow-hidden'
: 'h-svh min-h-0 min-w-0 overflow-hidden'
}
>
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b bg-background px-2 sm:px-4">
<SidebarTrigger className="size-11 shrink-0" />
<span className="hidden size-9 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground sm:grid">
<Building2Icon aria-hidden className="size-4" />
</span>
<div className="min-w-0 flex-1">
<h1 className="truncate font-heading font-medium text-sm sm:text-base">Model viewer</h1>
<p className="truncate text-muted-foreground text-xs">
{selection?.item.name ?? 'Autodesk project models'}
</p>
</div>
<UserAccountBadge account={account} size="sm" className="hidden max-w-52 sm:flex" />
<form action={signOutHref} method="post" className="shrink-0">
<Button
type="submit"
variant="ghost"
className="size-11 gap-1.5 px-0 sm:w-auto sm:px-3"
aria-label="Sign out of Autodesk"
>
<LogOutIcon aria-hidden />
<span className="hidden sm:inline">Sign out</span>
</Button>
</form>
</header>
<section className="relative min-h-0 min-w-0 flex-1 bg-muted" aria-label="Model viewer">
{urn && !viewerIssue ? (
<APSViewer
urn={urn}
getAccessToken={getAccessToken}
extensions={AEC_STARTER_EXTENSIONS}
profile="aec"
toolbar="native"
radius={0}
className="size-full"
onViewerReady={(viewer) => viewer.prefs.set('openPropertiesOnSelect', true)}
onError={(error) => setViewerIssue(viewerIssueFor(error))}
/>
) : (
<div className="absolute inset-0 grid place-items-center overflow-y-auto p-6">
{selection ? (
viewerIssue?.kind === 'error' ? (
<ModelStatusCard
translation={{
urn: urn ?? selection.item.id,
name: selectionLabel,
status: 'failed',
error: viewerIssue.detail,
}}
className="max-w-lg"
/>
) : (
<div role="status" className="max-w-sm text-center">
<h2 className="font-heading font-medium text-xl">No preview for this file</h2>
<p className="mt-2 break-words text-muted-foreground text-sm">
Autodesk has not produced a viewable version of “{selectionLabel}”. Choose
another file or version.
</p>
{viewerIssue && (
<details className="mt-4 text-left">
<summary className="w-fit text-muted-foreground text-xs">
Technical details
</summary>
<p className="mt-1 break-all font-mono text-muted-foreground text-xs">
{viewerIssue.detail}
</p>
</details>
)}
</div>
)
) : (
<div className="max-w-sm text-center">
<h2 className="font-heading font-medium text-xl">Choose a model</h2>
<p className="mt-2 text-muted-foreground text-sm">
Expand a hub, project, and folder, then open an item to load its tip version.
</p>
</div>
)}
</div>
)}
</section>
</SidebarInset>
</SidebarProvider>
)
}
export { ModelBrowser }
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { FinderGroup } from '@/components/ui/finder'
import type { HubTreeBranchNode, HubTreeNode } from '@/components/ui/hub-tree'
import {
type BrowsePathSegment,
type Item,
type ItemVersion,
normalizeSearchText,
} from '@/lib/project-types'
export interface FinderSearchEntry {
item: Item
version?: ItemVersion
path: BrowsePathSegment[]
caption?: string
}
export interface FinderScopeProject {
id: string
name: string
path: BrowsePathSegment[]
}
export interface FinderScope {
id: string
label: string
hubId: string
projects: FinderScopeProject[]
}
interface FinderSearchResponse {
entries?: Array<{ item: Item; version: ItemVersion; folder?: BrowsePathSegment }>
error?: string
}
const MAX_SEARCH_PROJECTS = 6
const SEARCH_PROJECT_CONCURRENCY = 2
async function settleWithConcurrency<T>(
tasks: (() => Promise<T>)[],
concurrency: number,
): Promise<PromiseSettledResult<T>[]> {
const results: PromiseSettledResult<T>[] = Array.from({ length: tasks.length })
let nextTask = 0
async function worker(): Promise<void> {
for (;;) {
const index = nextTask
nextTask += 1
const task = tasks[index]
if (!task) return
try {
results[index] = { status: 'fulfilled', value: await task() }
} catch (reason) {
results[index] = { status: 'rejected', reason }
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()))
return results
}
function browsePath(nodes: HubTreeNode[]): BrowsePathSegment[] {
return nodes.flatMap((node) => {
if (node.type === 'item' || node.type === 'version') return []
return [{ id: node.value.id, name: node.name, type: node.type }]
})
}
function finderScopeFor(
node: HubTreeBranchNode,
path: HubTreeNode[],
children: HubTreeNode[] | undefined,
): FinderScope | undefined {
const hub = path.find((entry) => entry.type === 'hub')
if (hub?.type !== 'hub') return undefined
const hubPath = browsePath([hub])
const projectNodes = (hub.id === node.id ? children : hub.children) ?? []
const projects = projectNodes.flatMap((child) =>
child.type === 'project'
? [
{
id: child.value.id,
name: child.name,
path: [...hubPath, { id: child.value.id, name: child.name, type: 'project' as const }],
},
]
: [],
)
return { id: hub.id, label: hub.name, hubId: hub.value.id, projects }
}
function loadedFinderEntries(
nodes: HubTreeNode[],
path: BrowsePathSegment[] = [],
parentItem?: Item,
): FinderSearchEntry[] {
const entries: FinderSearchEntry[] = []
for (const node of nodes) {
if (node.type === 'version') {
if (parentItem) entries.push({ item: parentItem, version: node.value, path })
continue
}
if (node.type === 'item') {
entries.push({ item: node.value, path })
if (node.children?.length) {
entries.push(...loadedFinderEntries(node.children, path, node.value))
}
continue
}
const nextPath = [...path, { id: node.value.id, name: node.name, type: node.type }]
if (node.children?.length) entries.push(...loadedFinderEntries(node.children, nextPath))
}
return entries
}
// Matches the server-side search: case-folded and diacritic-stripped, so
// "cana" finds "CAÑA VIVA" in both groups.
function uniqueFinderEntries(entries: FinderSearchEntry[]): FinderSearchEntry[] {
const seen = new Set<string>()
return entries.filter((entry) => {
if (seen.has(entry.item.id)) return false
seen.add(entry.item.id)
return true
})
}
export interface ModelFinder {
query: string
setQuery: (query: string) => void
scope: FinderScope | undefined
updateScope: (
node: HubTreeBranchNode,
path: HubTreeNode[],
children: HubTreeNode[] | undefined,
) => void
groups: FinderGroup[]
placeholder: string
emptyLabel: string
}
export function useModelFinder({
nodes,
treeEndpoint,
}: {
nodes: HubTreeNode[]
treeEndpoint: string
}): ModelFinder {
const [query, setQuery] = useState('')
const [scope, setScope] = useState<FinderScope>()
const [remoteKey, setRemoteKey] = useState<string>()
const [remoteEntries, setRemoteEntries] = useState<FinderSearchEntry[]>([])
const [remoteStatus, setRemoteStatus] = useState<'ready' | 'loading' | 'error'>('ready')
const [remoteError, setRemoteError] = useState<string>()
const loadedMatches = useMemo(() => {
const term = normalizeSearchText(query.trim())
if (!term) return []
return loadedFinderEntries(nodes).filter((entry) => {
const location = entry.path.map((segment) => segment.name).join(' ')
return normalizeSearchText(
`${entry.item.name} ${entry.version?.displayName ?? ''} ${location}`,
).includes(term)
})
}, [query, nodes])
const searchKey =
query.trim().length >= 2 && scope && scope.projects.length > 0
? `${scope.id}:${query.trim().toLocaleLowerCase()}`
: undefined
useEffect(() => {
const term = query.trim()
if (!searchKey || !scope) return
const controller = new AbortController()
const timer = window.setTimeout(() => {
setRemoteKey(searchKey)
setRemoteEntries([])
setRemoteStatus('loading')
setRemoteError(undefined)
const projects = scope.projects.slice(0, MAX_SEARCH_PROJECTS)
const limited = scope.projects.length > projects.length
void settleWithConcurrency(
projects.map((project) => async () => {
const params = new URLSearchParams({
kind: 'search',
hubId: scope.hubId,
projectId: project.id,
q: term,
})
const response = await fetch(`${treeEndpoint}?${params}`, {
cache: 'no-store',
signal: controller.signal,
})
const body = (await response.json()) as FinderSearchResponse
if (!response.ok || !body.entries) {
throw new Error(body.error ?? `Search in ${project.name} failed.`)
}
return body.entries.map((entry) => ({
item: entry.item,
version: entry.version,
path: entry.folder ? [...project.path, entry.folder] : project.path,
}))
}),
SEARCH_PROJECT_CONCURRENCY,
).then((results) => {
if (controller.signal.aborted) return
const found = results.flatMap((result) =>
result.status === 'fulfilled' ? result.value : [],
)
const failures = results.filter((result) => result.status === 'rejected').length
setRemoteEntries(uniqueFinderEntries(found))
if (failures === 0 && !limited) {
setRemoteStatus('ready')
return
}
setRemoteStatus('error')
setRemoteError(
failures === results.length
? `Search in ${scope.label} failed. Keep typing to retry.`
: limited && failures === 0
? `Search is limited to the first ${MAX_SEARCH_PROJECTS} projects in this hub.`
: 'Some projects could not be searched. Results may be incomplete.',
)
})
}, 250)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [query, scope, searchKey, treeEndpoint])
const remoteMatches = useMemo(() => {
if (!searchKey || remoteKey !== searchKey) return []
const loadedIds = new Set(loadedMatches.map((entry) => entry.item.id))
return remoteEntries.filter((entry) => !loadedIds.has(entry.item.id))
}, [loadedMatches, searchKey, remoteEntries, remoteKey])
const activeRemoteStatus = searchKey
? remoteKey === searchKey
? remoteStatus
: 'loading'
: 'ready'
const activeRemoteError = remoteKey === searchKey ? remoteError : undefined
return {
query,
setQuery,
scope,
updateScope: (node, path, children) => {
const next = finderScopeFor(node, path, children)
// A hub whose projects have not loaded never takes (or clobbers) the scope.
if (next && next.projects.length > 0) setScope(next)
},
groups: [
{ id: 'loaded', label: 'Loaded files', entries: loadedMatches },
{
id: 'remote',
label: scope ? `In ${scope.label}` : 'Hub search',
status: activeRemoteStatus,
error: activeRemoteError,
entries: remoteMatches,
},
],
placeholder: scope ? `Search in ${scope.label}` : 'Find a loaded file',
emptyLabel: scope
? query.trim().length < 2
? 'Type at least 2 characters to search across projects.'
: `No results for "${query.trim()}" in ${scope.label}. Try a different term.`
: 'Expand a hub to search its projects.',
}
}
import { APS_BASE_URL, authHeaders, TokenError } from 'aec-auth'
import { cookies } from 'next/headers'
import type {
HubTreeFolderNode,
HubTreeHubNode,
HubTreeItemNode,
HubTreeNode,
HubTreeProjectNode,
HubTreeVersionNode,
} from '@/components/ui/hub-tree'
import { appOrigin, getSessionToken, openSession, SESSION_COOKIE } from '@/lib/acc-auth'
import {
type ApsFolderDoc,
type ApsHubDoc,
type ApsItemDoc,
type ApsProjectDoc,
type ApsVersionDoc,
fromApsFolder,
fromApsHub,
fromApsItem,
fromApsProject,
fromApsVersion,
} from '@/lib/aps-data-preset'
import {
type BrowsePathSegment,
type Item,
type ItemVersion,
normalizeSearchText,
} from '@/lib/project-types'
interface JsonApiDocument<T> {
data?: T
included?: ApsVersionDoc[]
links?: { next?: unknown }
}
interface TypedFolderDoc extends ApsFolderDoc {
type: 'folders'
}
interface TypedItemDoc extends ApsItemDoc {
type: 'items'
}
interface SearchVersionDoc extends ApsVersionDoc {
relationships?: ApsVersionDoc['relationships'] & {
item?: { data?: { id?: string } | null }
}
}
interface SearchEntry {
item: Item
version: ItemVersion
folder?: BrowsePathSegment
}
interface ItemParentDoc extends ApsItemDoc {
relationships?: ApsItemDoc['relationships'] & {
parent?: { data?: { id?: string } | null }
}
}
interface FolderParentDoc extends ApsFolderDoc {
relationships?: {
parent?: { data?: { id?: string } | null }
}
}
type TreeKind =
| 'hubs'
| 'projects'
| 'top-folders'
| 'folder-contents'
| 'versions'
| 'search'
| 'path'
type BrowseKind = Exclude<TreeKind, 'search' | 'path'>
class MissingQueryParameterError extends Error {}
const FOLDER_PAGE_LIMIT = 200
const MAX_FOLDER_PAGES = 20
const MAX_SEARCH_PAGES = 2
const MAX_SEARCH_FOLDERS = 5
const SEARCH_FOLDER_CONCURRENCY = 2
const SEARCH_MIN_QUERY_LENGTH = 2
const MAX_SEARCH_MATCHES = 50
const MAX_PATH_DEPTH = 20
function apiBase(origin: string): string {
const configured = process.env.APS_AUTH_BASE_URL
if (!configured) return APS_BASE_URL
return configured.startsWith('/') ? `${origin}${configured}` : configured
}
function segment(value: string): string {
return encodeURIComponent(value).replaceAll('%3A', ':')
}
async function apsGet<T>(url: string, token: Awaited<ReturnType<typeof getSessionToken>>) {
const response = await fetch(url, {
headers: { ...authHeaders(token), Accept: 'application/json' },
cache: 'no-store',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
return (await response.json()) as T
}
function required(searchParams: URLSearchParams, name: string): string {
const value = searchParams.get(name)
if (!value) throw new MissingQueryParameterError(`Missing required query parameter: ${name}`)
return value
}
function hubNode(doc: ApsHubDoc): HubTreeHubNode {
const value = fromApsHub(doc)
return { id: `hub:${value.id}`, name: value.name, type: 'hub', value, hasChildren: true }
}
function projectNode(doc: ApsProjectDoc, hubId: string): HubTreeProjectNode {
const adapted = fromApsProject(doc)
const value = { ...adapted, hubId: adapted.hubId ?? hubId }
return {
id: `project:${hubId}:${value.id}`,
name: value.name,
type: 'project',
value,
hasChildren: true,
}
}
function folderNode(doc: ApsFolderDoc, projectId: string): HubTreeFolderNode {
const value = fromApsFolder(doc)
return {
id: `folder:${projectId}:${value.id}`,
name: value.name,
type: 'folder',
value,
hasChildren: true,
}
}
function itemNode(doc: ApsItemDoc, projectId: string, tip?: ApsVersionDoc): HubTreeItemNode {
const value = fromApsItem(doc, tip)
return {
id: `item:${projectId}:${value.id}`,
name: value.name,
type: 'item',
value,
hasChildren: true,
}
}
function versionNode(doc: ApsVersionDoc, projectId: string, itemId: string): HubTreeVersionNode {
const value = fromApsVersion(doc)
return {
id: `version:${projectId}:${itemId}:${value.id}`,
name: value.displayName,
type: 'version',
value,
hasChildren: false,
}
}
async function loadFolderContents(
base: string,
token: Awaited<ReturnType<typeof getSessionToken>>,
projectId: string,
folderId: string,
): Promise<HubTreeNode[]> {
const documents: JsonApiDocument<(TypedFolderDoc | TypedItemDoc)[]>[] = []
for (let page = 0; page < MAX_FOLDER_PAGES; page += 1) {
const document = await apsGet<JsonApiDocument<(TypedFolderDoc | TypedItemDoc)[]>>(
`${base}/data/v1/projects/${segment(projectId)}/folders/${segment(folderId)}/contents?page[number]=${page}&page[limit]=${FOLDER_PAGE_LIMIT}`,
token,
)
documents.push(document)
if (!document.links?.next) break
}
const tips = new Map(
documents.flatMap((document) => document.included ?? []).map((tip) => [tip.id, tip]),
)
return documents.flatMap((document) =>
(document.data ?? []).map((resource) =>
resource.type === 'folders'
? folderNode(resource, projectId)
: itemNode(resource, projectId, tips.get(resource.relationships?.tip?.data?.id ?? '')),
),
)
}
async function searchFolder(
base: string,
token: Awaited<ReturnType<typeof getSessionToken>>,
projectId: string,
folderId: string,
query: string,
): Promise<SearchEntry[]> {
const needle = normalizeSearchText(query)
const entries: SearchEntry[] = []
const seenVersionIds = new Set<string>()
function collect(docs: SearchVersionDoc[]): void {
for (const doc of docs) {
if (entries.length >= MAX_SEARCH_MATCHES) return
const itemId = doc.relationships?.item?.data?.id
if (!itemId) continue
const version = fromApsVersion(doc)
if (seenVersionIds.has(version.id)) continue
if (!normalizeSearchText(version.displayName).includes(needle)) continue
seenVersionIds.add(version.id)
entries.push({
item: {
id: itemId,
name: version.displayName,
type: 'item' as const,
tip: version,
translationStatus: version.derivativeUrn ? ('success' as const) : ('pending' as const),
},
version,
})
}
}
async function collectPages(filtered: boolean): Promise<void> {
let hasNext = true
for (
let page = 0;
hasNext && page < MAX_SEARCH_PAGES && entries.length < MAX_SEARCH_MATCHES;
page += 1
) {
const searchUrl = new URL(
`${base}/data/v1/projects/${segment(projectId)}/folders/${segment(folderId)}/search`,
)
if (filtered) searchUrl.searchParams.set('filter[attributes.displayName]-contains', query)
searchUrl.searchParams.set('page[number]', String(page))
searchUrl.searchParams.set('page[limit]', String(FOLDER_PAGE_LIMIT))
const document = await apsGet<JsonApiDocument<SearchVersionDoc[]>>(searchUrl.href, token)
hasNext = Boolean(document.links?.next)
collect(document.data ?? [])
}
}
// Two passes: the APS-filtered query applies its (case-sensitive) filter
// before pagination, so the page cap covers matches anywhere in the folder;
// the unfiltered sweep adds normalized matches within its page window.
await collectPages(true)
await collectPages(false)
return entries
}
async function searchProject(
base: string,
token: Awaited<ReturnType<typeof getSessionToken>>,
hubId: string,
projectId: string,
query: string,
): Promise<SearchEntry[]> {
const document = await apsGet<JsonApiDocument<ApsFolderDoc[]>>(
`${base}/project/v1/hubs/${segment(hubId)}/projects/${segment(projectId)}/topFolders`,
token,
)
const folders = (document.data ?? []).map(fromApsFolder).slice(0, MAX_SEARCH_FOLDERS)
const results: SearchEntry[][] = Array.from({ length: folders.length })
let nextFolder = 0
async function worker(): Promise<void> {
for (;;) {
const index = nextFolder
nextFolder += 1
const folder = folders[index]
if (!folder) return
const entries = await searchFolder(base, token, projectId, folder.id, query)
results[index] = entries.map((entry) => ({
...entry,
folder: { id: folder.id, name: folder.name, type: 'folder' as const },
}))
}
}
await Promise.all(
Array.from({ length: Math.min(SEARCH_FOLDER_CONCURRENCY, folders.length) }, () => worker()),
)
return results.flat()
}
async function itemFolderPath(
base: string,
token: Awaited<ReturnType<typeof getSessionToken>>,
projectId: string,
itemId: string,
topFolderId: string,
): Promise<BrowsePathSegment[] | undefined> {
const itemDocument = await apsGet<JsonApiDocument<ItemParentDoc>>(
`${base}/data/v1/projects/${segment(projectId)}/items/${segment(itemId)}`,
token,
)
let folderId = itemDocument.data?.relationships?.parent?.data?.id
const segments: BrowsePathSegment[] = []
for (let depth = 0; folderId && folderId !== topFolderId && depth < MAX_PATH_DEPTH; depth += 1) {
const folderDocument = await apsGet<JsonApiDocument<FolderParentDoc>>(
`${base}/data/v1/projects/${segment(projectId)}/folders/${segment(folderId)}`,
token,
)
if (!folderDocument.data) return undefined
const folder = fromApsFolder(folderDocument.data)
segments.unshift({ id: folder.id, name: folder.name, type: 'folder' })
folderId = folderDocument.data.relationships?.parent?.data?.id
}
return folderId === topFolderId ? segments : undefined
}
async function loadNodes(
kind: BrowseKind,
searchParams: URLSearchParams,
base: string,
token: Awaited<ReturnType<typeof getSessionToken>>,
): Promise<HubTreeNode[]> {
if (kind === 'hubs') {
const document = await apsGet<JsonApiDocument<ApsHubDoc[]>>(`${base}/project/v1/hubs`, token)
return (document.data ?? []).map(hubNode)
}
const hubId = kind === 'projects' || kind === 'top-folders' ? required(searchParams, 'hubId') : ''
const projectId = kind === 'projects' ? '' : required(searchParams, 'projectId')
if (kind === 'projects') {
const document = await apsGet<JsonApiDocument<ApsProjectDoc[]>>(
`${base}/project/v1/hubs/${segment(hubId)}/projects`,
token,
)
return (document.data ?? []).map((doc) => projectNode(doc, hubId))
}
if (kind === 'top-folders') {
const document = await apsGet<JsonApiDocument<ApsFolderDoc[]>>(
`${base}/project/v1/hubs/${segment(hubId)}/projects/${segment(projectId)}/topFolders`,
token,
)
return (document.data ?? []).map((doc) => folderNode(doc, projectId))
}
if (kind === 'folder-contents') {
return loadFolderContents(base, token, projectId, required(searchParams, 'folderId'))
}
const itemId = required(searchParams, 'itemId')
const document = await apsGet<JsonApiDocument<ApsVersionDoc[]>>(
`${base}/data/v1/projects/${segment(projectId)}/items/${segment(itemId)}/versions?page[limit]=200`,
token,
)
return (document.data ?? []).map((doc) => versionNode(doc, projectId, itemId))
}
/**
* Lazy, session-authenticated Data Management tree endpoint.
*
* GET /api/models/tree?kind=hubs
* GET /api/models/tree?kind=projects&hubId=...
* GET /api/models/tree?kind=top-folders&hubId=...&projectId=...
* GET /api/models/tree?kind=folder-contents&projectId=...&folderId=...
* GET /api/models/tree?kind=versions&projectId=...&itemId=...
* GET /api/models/tree?kind=search&projectId=...&folderId=...&q=...
* GET /api/models/tree?kind=search&hubId=...&projectId=...&q=... (all top folders)
* GET /api/models/tree?kind=path&projectId=...&itemId=...&topFolderId=...
*/
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url)
const kind = url.searchParams.get('kind') as TreeKind | null
if (
!kind ||
!['hubs', 'projects', 'top-folders', 'folder-contents', 'versions', 'search', 'path'].includes(
kind,
)
) {
return Response.json({ error: 'Unknown tree request.' }, { status: 400 })
}
try {
const origin = appOrigin(url.origin)
const cookieStore = await cookies()
const session = await openSession(cookieStore.get(SESSION_COOKIE)?.value)
if (!session) {
return Response.json({ error: 'Sign in with Autodesk to browse models.' }, { status: 401 })
}
const token = await getSessionToken(origin, session)
if (kind === 'search') {
const query = required(url.searchParams, 'q').trim()
if (query.length < SEARCH_MIN_QUERY_LENGTH) {
return Response.json({ entries: [] }, { headers: { 'Cache-Control': 'no-store' } })
}
const folderId = url.searchParams.get('folderId')
const entries = folderId
? await searchFolder(
apiBase(origin),
token,
required(url.searchParams, 'projectId'),
folderId,
query,
)
: await searchProject(
apiBase(origin),
token,
required(url.searchParams, 'hubId'),
required(url.searchParams, 'projectId'),
query,
)
return Response.json({ entries }, { headers: { 'Cache-Control': 'no-store' } })
}
if (kind === 'path') {
const segments = await itemFolderPath(
apiBase(origin),
token,
required(url.searchParams, 'projectId'),
required(url.searchParams, 'itemId'),
required(url.searchParams, 'topFolderId'),
)
if (!segments) {
return Response.json(
{ error: 'The item location could not be resolved.' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
)
}
return Response.json({ segments }, { headers: { 'Cache-Control': 'no-store' } })
}
const nodes = await loadNodes(kind, url.searchParams, apiBase(origin), token)
return Response.json({ nodes }, { headers: { 'Cache-Control': 'no-store' } })
} catch (error) {
if (error instanceof MissingQueryParameterError) {
return Response.json({ error: error.message }, { status: 400 })
}
if (
error instanceof TokenError &&
(error.code === 'consent_required' || error.code === 'grant_invalid')
) {
return Response.json(
{ error: 'Reconnect Autodesk to browse models.' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
)
}
console.error('Model tree request failed', error)
return Response.json(
{ error: 'The Autodesk project tree could not be loaded.' },
{ status: 502, headers: { 'Cache-Control': 'no-store' } },
)
}
}
import { APS_BASE_URL, type TokenSource } from 'aec-auth'
import { apsOAuth, memoryVaultStore, vaultTokenSource } from 'aec-auth/vault'
import { connection } from 'next/server'
const VIEWER_SCOPES = ['viewables:read'] as const
const tokenSources = new Map<string, TokenSource>()
// Vercel sets VERCEL_PROJECT_PRODUCTION_URL on every deployment, so a one-click
// deploy has a trusted origin before its owner knows the URL.
function deploymentOrigin(): string | undefined {
if (process.env.APP_ORIGIN) return process.env.APP_ORIGIN
const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL
return vercel ? `https://${vercel}` : undefined
}
function apiBase(origin: string): string {
const configured = process.env.APS_AUTH_BASE_URL
if (!configured) return APS_BASE_URL
if (!configured.startsWith('/')) return configured
const trusted = deploymentOrigin()
if (!trusted && process.env.NODE_ENV === 'production') {
throw new Error('APP_ORIGIN is required with a relative APS_AUTH_BASE_URL in production')
}
return `${trusted ? new URL(trusted).origin : origin}${configured}`
}
function getViewerTokenSource(base: string): TokenSource {
const existing = tokenSources.get(base)
if (existing) return existing
const clientId = process.env.APS_CLIENT_ID
const clientSecret = process.env.APS_CLIENT_SECRET
if (!clientId || !clientSecret) throw new Error('APS viewer credentials are not configured')
const source = vaultTokenSource({
store: memoryVaultStore(),
providers: {
aps: apsOAuth({ clientId, clientSecret, baseUrl: base === APS_BASE_URL ? undefined : base }),
},
})
tokenSources.set(base, source)
return source
}
/** A 2-legged viewer token with the minimum Model Derivative read scope. */
export async function GET(request: Request): Promise<Response> {
await connection()
try {
const base = apiBase(new URL(request.url).origin)
const token = await getViewerTokenSource(base).getToken({
provider: 'aps',
subject: { type: 'app' },
scopes: VIEWER_SCOPES,
forceRefresh: true,
})
return Response.json(
{
accessToken: token.token,
expiresInSeconds: Math.max(1, Math.floor((token.expiresAt - Date.now()) / 1000)),
},
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error) {
console.error('Viewer token request failed', error)
return Response.json(
{ error: 'The viewer token is unavailable.' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
}