diff --git a/src/components/FolderBrowser.tsx b/src/components/FolderBrowser.tsx new file mode 100644 index 0000000..1305976 --- /dev/null +++ b/src/components/FolderBrowser.tsx @@ -0,0 +1,126 @@ +import { ChevronRight, Folder, FileBox, ArrowUp, Home, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { breadcrumbs, formatBytes, isSupportedModel, parentPath, type FBItem } from '@/lib/filebrowser'; + +interface FolderBrowserProps { + origin: string; + path: string; + items: FBItem[]; + loading?: boolean; + selectedName: string | null; + showAll: boolean; + onToggleShowAll: (v: boolean) => void; + onNavigate: (path: string) => void; + onSelect: (name: string) => void; +} + +export function FolderBrowser({ + origin, path, items, loading, selectedName, showAll, onToggleShowAll, onNavigate, onSelect, +}: FolderBrowserProps) { + const crumbs = breadcrumbs(path); + const visible = items.filter((it) => it.isDir || showAll || isSupportedModel(it)); + const dirs = visible.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name)); + const files = visible.filter((i) => !i.isDir).sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+ {/* Breadcrumb */} +
+ {new URL(origin).hostname} + + + {crumbs.map((c, i) => ( + + + + + ))} +
+ + {/* Toolbar */} +
+ + +
+ + {/* Listagem */} + + {loading ? ( +
+ + Carregando… +
+ ) : visible.length === 0 ? ( +
+ Pasta vazia ou sem arquivos suportados +
+ ) : ( +
+ {dirs.map((it) => ( + + ))} + {files.map((it) => { + const supported = isSupportedModel(it); + const selected = selectedName === it.name; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/lib/filebrowser.ts b/src/lib/filebrowser.ts new file mode 100644 index 0000000..fdb4158 --- /dev/null +++ b/src/lib/filebrowser.ts @@ -0,0 +1,133 @@ +/** + * Cliente leve para instâncias FileBrowser (https://filebrowser.org). + * Suporta NoAuth (POST /api/login vazio) e listagem de pastas via /api/resources. + */ + +export const SUPPORTED_EXT = ['glb', 'obj', 'stl', 'ifc'] as const; +export type SupportedFolderExt = (typeof SUPPORTED_EXT)[number]; + +export interface FBItem { + name: string; + isDir: boolean; + size: number; + extension: string; // ".ifc" (com ponto) — normalizamos + modified?: string; +} + +export interface FolderListing { + origin: string; + /** Caminho da pasta sempre começando e terminando com "/" (ex.: "/modelos/aco/") */ + path: string; + token: string | null; + items: FBItem[]; +} + +/** Tenta obter um JWT de uma instância FileBrowser NoAuth. */ +export async function getFileBrowserToken(origin: string): Promise { + try { + const r = await fetch(`${origin}/api/login`, { method: 'POST', mode: 'cors' }); + if (!r.ok) return null; + const txt = (await r.text()).trim(); + if (txt && txt.split('.').length === 3) return txt; + return null; + } catch { + return null; + } +} + +/** Garante "/" inicial e final. */ +function normalizeFolderPath(p: string): string { + let path = p || '/'; + if (!path.startsWith('/')) path = '/' + path; + if (!path.endsWith('/')) path = path + '/'; + return path; +} + +/** Lista o conteúdo de uma pasta a partir de uma URL completa OU de origin+path. */ +export async function listFolder(input: { folderUrl?: string; origin?: string; path?: string }): Promise { + let origin: string; + let path: string; + + if (input.folderUrl) { + const u = new URL(input.folderUrl); + origin = u.origin; + path = normalizeFolderPath(decodeURIComponent(u.pathname)); + } else { + if (!input.origin) throw new Error('origin obrigatório'); + origin = input.origin; + path = normalizeFolderPath(input.path || '/'); + } + + const token = await getFileBrowserToken(origin); + const auth = token ? `?auth=${encodeURIComponent(token)}` : ''; + // FileBrowser API: GET /api/resources/ + const apiUrl = `${origin}/api/resources${path}${auth}`; + + const res = await fetch(apiUrl, { mode: 'cors' }); + if (!res.ok) { + throw new Error( + `Não foi possível listar a pasta (HTTP ${res.status}). ` + + `Verifique se a URL é de uma instância FileBrowser e se a pasta é pública.` + ); + } + const ct = res.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + throw new Error('Endpoint não retornou JSON — provavelmente não é um servidor FileBrowser.'); + } + + const json = await res.json(); + const rawItems: any[] = Array.isArray(json?.items) ? json.items : []; + const items: FBItem[] = rawItems.map((it) => ({ + name: String(it.name ?? ''), + isDir: Boolean(it.isDir), + size: Number(it.size ?? 0), + extension: String(it.extension ?? '').toLowerCase(), + modified: it.modified, + })); + + return { origin, path, token, items }; +} + +/** Monta a URL /api/raw para download direto de um arquivo. */ +export function buildRawUrl(origin: string, path: string, name: string, token: string | null): string { + const folder = normalizeFolderPath(path); + const auth = token ? `?auth=${encodeURIComponent(token)}` : ''; + return `${origin}/api/raw${folder}${encodeURIComponent(name)}${auth}`; +} + +/** True se o item for arquivo 3D suportado. */ +export function isSupportedModel(item: FBItem): boolean { + if (item.isDir) return false; + const ext = item.extension.replace(/^\./, '').toLowerCase(); + return (SUPPORTED_EXT as readonly string[]).includes(ext); +} + +/** Sobe um nível na hierarquia de pastas. Retorna "/" para a raiz. */ +export function parentPath(path: string): string { + const norm = normalizeFolderPath(path); + if (norm === '/') return '/'; + const trimmed = norm.slice(0, -1); // remove trailing / + const idx = trimmed.lastIndexOf('/'); + return idx <= 0 ? '/' : trimmed.slice(0, idx + 1); +} + +/** Quebra "/a/b/c/" em [{name:'a',path:'/a/'},{name:'b',path:'/a/b/'},...] */ +export function breadcrumbs(path: string): { name: string; path: string }[] { + const norm = normalizeFolderPath(path); + if (norm === '/') return []; + const parts = norm.split('/').filter(Boolean); + const out: { name: string; path: string }[] = []; + let acc = ''; + for (const p of parts) { + acc += '/' + p; + out.push({ name: p, path: acc + '/' }); + } + return out; +} + +export function formatBytes(n: number): string { + if (!n || n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +}