diff --git a/.lovable/plan.md b/.lovable/plan.md index 59127ba..649cb92 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,52 +1,62 @@ -# Navegador de pasta na nuvem (FileBrowser) +## Resposta curta -Hoje o `CloudLoader` exige a URL exata do arquivo (ex.: `…/demo.ifc`). Vou estender para também aceitar uma **URL de pasta** (ex.: `https://store.tracksteel.com.br/modelos/`). Ao clicar em **Abrir pasta**, o app lista os arquivos `.ifc / .obj / .stl / .glb` daquela pasta, o usuário seleciona um e clica em **Carregar**. +Sim, é totalmente possível — hoje o app só carrega 1 modelo por vez (`model: ModelInfo | null` no store), mas a arquitetura (Three.js + R3F) suporta naturalmente múltiplas peças em uma mesma cena. Precisa migrar de "modelo único" para "lista de modelos", cada um com seu próprio transform (posição/rotação/escala) e seleção ativa. -## Como vai funcionar (UX) +## O que muda -1. No diálogo "Carregar modelo da nuvem" passa a haver dois modos, lado a lado: - - **Arquivo** (comportamento atual) — cola URL do arquivo, clica Carregar. - - **Pasta** (novo) — cola URL da pasta, clica **Abrir pasta**. -2. Ao abrir uma pasta: - - Mostra breadcrumb do caminho atual (clicável para subir). - - Lista subpastas (📁) e arquivos 3D suportados (📄 com extensão e tamanho). - - Esconde por padrão arquivos não suportados; um toggle "Mostrar todos" libera os demais. - - Clicar numa subpasta navega para dentro dela; clicar num arquivo o seleciona. -3. Botão **Carregar selecionado** dispara o mesmo fluxo já existente (`smartFetch` + conversão IFC/OBJ/STL → GLB + `addModel`). -4. Funciona em append: respeita o limite de 5 peças simultâneas e o toast de erro já existente. +### 1. Store (`useModelStore.ts`) +- Substituir `model: ModelInfo | null` por `models: SceneModel[]` (até 5 simultâneos para manter 72 FPS no Quest 3). +- Cada `SceneModel` tem: `id`, `fileName`, `url`, `fineTuning` próprio, `visible`, `color` (cor de identificação), `locked`. +- Adicionar `activeModelId: string | null` — o "modelo selecionado" recebe os controles de fine-tuning, escala e medição. +- Ações: `addModel`, `removeModel`, `duplicateModel`, `setActiveModel`, `updateModelTransform`, `toggleVisibility`. +- Compatibilidade: `model` (singular) continua exposto como getter derivado do `activeModelId` para não quebrar telas legadas. -## Detecção e API (FileBrowser) +### 2. Cena 3D (`ModelViewer.tsx`) +- Renderizar `models.map(m => )`. +- Cada `` aplica seu próprio transform (já existe a lógica, só vira por-instância). +- Outline/halo sutil no modelo ativo para feedback visual. +- Click em um modelo → torna-o ativo (raycaster identifica qual instância foi clicada). -FileBrowser expõe `GET /api/resources/{caminho}?auth=` que devolve JSON com `items: [{ name, isDir, size, extension, ... }]`. Vou: +### 3. UI Desktop (`Viewer.tsx`) +- Novo painel **"Cena"** lateral listando os modelos carregados: + - Nome do arquivo, cor, ícones de visibilidade (olho), trava (cadeado), duplicar, remover. + - Item destacado = ativo. Click seleciona. +- Botão **"+ Adicionar modelo"** abre o mesmo fluxo de upload/cloud já existente, mas em modo "append" em vez de "replace". +- `FineTuningControls`, `ScaleSelector` passam a operar sobre o modelo ativo. -- Reaproveitar `tryFileBrowserToken(origin)` (NoAuth) já implementado. -- A partir da URL colada, derivar `origin` + `path` (path da pasta, com `/` final). -- Chamar `/api/resources{path}?auth=` e renderizar `items`. -- Para baixar o arquivo escolhido, montar `…/api/raw{path}/{name}?auth=` (mesma lógica do `smartFetch`). -- Filtro de extensões: `glb, obj, stl, ifc` (via `getSupportedExtension`). -- Mensagens de erro claras quando: não é FileBrowser, CORS bloqueia, token NoAuth indisponível, pasta vazia. +### 4. UI XR/AR (`XRHud.tsx`) +- Painel flutuante com a lista de peças (mesmos botões compactos). +- Botão "Adicionar" abre o `CloudLoader` já existente em modo append. +- Gestos do controle: o grab (já implementado em `XRGrabbable`) move só o modelo ativo / o que foi pinçado diretamente. -## Escopo desta entrega - -Suporte só a **FileBrowser** (que é o caso atual `store.tracksteel.com.br`). Dropbox/OneDrive/Drive ficam de fora — exigiriam OAuth e um connector próprio (posso fazer numa próxima rodada se quiser). - -## Arquivos - -- **Editar** `src/components/CloudLoader.tsx` - - Adicionar tabs "Arquivo / Pasta" no `DialogContent`. - - Adicionar estado `mode`, `folderUrl`, `currentPath`, `items`, `selectedItem`, `browsing`. - - Manter `handleLoad(rawUrl)` atual para o modo Arquivo. -- **Criar** `src/lib/filebrowser.ts` - - `getFileBrowserToken(origin)` (move/extrai a função existente). - - `listFolder(folderUrl): Promise<{ origin, path, token, items }>`. - - `buildRawUrl(origin, path, name, token)`. - - `SUPPORTED_EXT = ['glb','obj','stl','ifc']`. -- **Criar** `src/components/FolderBrowser.tsx` - - Componente puro de UI: recebe `items`, `currentPath`, callbacks `onNavigate`, `onSelect`, `onLoad`. Mostra breadcrumb, lista, botão Carregar selecionado. -- **Sem mudanças** em `useModelStore`, `convertIFC`, `convertToGLB` — fluxo de download/conversão é o mesmo. +### 5. Funcionalidades dependentes +- **Medições**: passam a ter `modelId` opcional (de qual peça partiu o ponto). Continuam globais na cena — útil para medir GAP entre peças. +- **Checklist**: por enquanto continua global (uma inspeção por sessão). Pode-se evoluir depois para checklist por peça. +- **Screenshot/PDF**: capturam a cena inteira (todas as peças visíveis), sem mudança. +- **Compare mode**: opera sobre a cena toda, sem mudança. ## Limitações conhecidas -- Funciona somente em FileBrowser com NoAuth ou já autenticado pelo navegador. -- Servidor precisa permitir CORS para o domínio do app (já é o caso de `store.tracksteel.com.br`). -- Listagens muito grandes (>500 arquivos) podem ficar lentas; sem paginação nesta versão. +- **Performance**: GLB grandes somados podem derrubar FPS no Quest 3. Vou impor limite de **5 modelos simultâneos** com aviso. +- **Conversão IFC**: cada arquivo passa pelo conversor cliente (`convertIFC.ts`) já existente — sem mudança. +- **Colisão física**: as peças podem se sobrepor visualmente (não há detecção de colisão). Para "empilhar" o usuário posiciona manualmente via grab/fine-tuning. Snap entre peças seria uma evolução futura. +- **Anotações em tela**: ficam vinculadas a coordenadas de mundo, não ao modelo — se você mover o modelo depois, a anotação não acompanha (limitação aceitável v1). + +## Fora do escopo desta v1 + +- Snap automático/encaixe entre peças (próxima iteração) +- Detecção de colisão / física +- Agrupar peças (parent/child) +- Checklist independente por peça +- Persistência da cena multi-modelo (salvar/carregar layout) + +## Arquivos afetados + +- `src/stores/useModelStore.ts` — refactor central +- `src/components/three/ModelViewer.tsx` — render por instância +- `src/pages/Viewer.tsx` — painel de cena + botão "Adicionar" +- `src/components/XRHud.tsx` — lista de peças no AR +- `src/components/FineTuningControls.tsx` — opera no ativo +- `src/components/ScaleSelector.tsx` — opera no ativo +- `src/components/three/XRGrabbable.tsx` — grab por instância +- novo `src/components/SceneModelList.tsx` — componente reutilizável da lista diff --git a/src/components/CloudLoader.tsx b/src/components/CloudLoader.tsx index 69e1651..d8af547 100644 --- a/src/components/CloudLoader.tsx +++ b/src/components/CloudLoader.tsx @@ -1,23 +1,17 @@ import { useState, useCallback } from 'react'; -import { Cloud, Loader2, Link2, FolderOpen } from 'lucide-react'; +import { Cloud, Loader2, Link2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useModelStore } from '@/stores/useModelStore'; import { toast } from 'sonner'; import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB'; import { convertIFCtoGLB } from '@/lib/convertIFC'; -import { - buildRawUrl, getFileBrowserToken, listFolder, type FBItem, type FolderListing, -} from '@/lib/filebrowser'; -import { FolderBrowser } from './FolderBrowser'; const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc'; -const DEFAULT_FOLDER = 'https://store.tracksteel.com.br/'; const PRESETS: { label: string; url: string }[] = [ { label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL }, @@ -41,6 +35,20 @@ function normalizeCloudUrl(input: string): string { } } +/** Tenta obter um token JWT de uma instância FileBrowser NoAuth (POST /api/login vazio). */ +async function tryFileBrowserToken(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(); + // FileBrowser devolve o JWT como texto puro + if (txt && txt.split('.').length === 3) return txt; + return null; + } catch { + return null; + } +} + /** Faz fetch tentando vários endpoints típicos de hosts que servem SPA (FileBrowser etc.). */ async function smartFetch(rawUrl: string): Promise { const url = normalizeCloudUrl(rawUrl.trim()); @@ -53,12 +61,17 @@ async function smartFetch(rawUrl: string): Promise { const u = new URL(url); const path = u.pathname.startsWith('/api/raw/') ? u.pathname : `/api/raw${u.pathname}`; + // 1ª tentativa: /api/raw/... cru try { const r = await fetch(`${u.origin}${path}${u.search}`, { mode: 'cors' }); if (r.ok && !(r.headers.get('content-type') || '').includes('text/html')) return r; + if (r.status !== 401) { + // pode ser outro erro — segue para fluxo de token + } } catch { /* continua */ } - const token = await getFileBrowserToken(u.origin); + // 2ª tentativa: pega token JWT NoAuth e usa ?auth= + const token = await tryFileBrowserToken(u.origin); if (token) { const sep = u.search ? '&' : '?'; const r = await fetch(`${u.origin}${path}${u.search}${sep}auth=${encodeURIComponent(token)}`, { mode: 'cors' }); @@ -72,7 +85,9 @@ async function smartFetch(rawUrl: string): Promise { } interface CloudLoaderProps { + /** Renderiza como botão compacto (para HUD do AR) */ compact?: boolean; + /** Variante visual do botão */ variant?: 'default' | 'outline'; className?: string; } @@ -81,62 +96,40 @@ export function CloudLoader({ compact = false, variant = 'outline', className }: const addModel = useModelStore((s) => s.addModel); const modelsCount = useModelStore((s) => s.models.length); const maxModels = useModelStore((s) => s.maxModels); - const [open, setOpen] = useState(false); - const [tab, setTab] = useState<'file' | 'folder'>('file'); + const [url, setUrl] = useState(DEFAULT_URL); const [loading, setLoading] = useState(false); - // ---- modo Arquivo - const [url, setUrl] = useState(DEFAULT_URL); - - // ---- modo Pasta - const [folderInput, setFolderInput] = useState(DEFAULT_FOLDER); - const [listing, setListing] = useState(null); - const [browsingLoading, setBrowsingLoading] = useState(false); - const [selectedName, setSelectedName] = useState(null); - const [showAll, setShowAll] = useState(false); - - /** Importa um buffer já baixado para a cena (conversão se necessário). */ - const importBuffer = useCallback(async (buffer: ArrayBuffer, fileName: string) => { - const ext = getSupportedExtension(fileName); - if (!ext) throw new Error('Extensão não suportada (use GLB, OBJ, STL ou IFC)'); - - let blob: Blob, outName = fileName, outSize = buffer.byteLength; - if (ext === 'glb') { - blob = new Blob([buffer], { type: 'model/gltf-binary' }); - } else if (ext === 'ifc') { - const r = await convertIFCtoGLB(buffer, fileName); - blob = r.blob; outName = r.fileName; outSize = r.fileSize; - } else { - const r = await convertToGLB(buffer, ext, fileName); - blob = r.blob; outName = r.fileName; outSize = r.fileSize; - } - const blobUrl = URL.createObjectURL(blob); - addModel({ fileName: outName, fileSize: outSize, url: blobUrl }); - return outName; - }, [addModel]); - - const ensureCanAdd = useCallback(() => { + const handleLoad = useCallback(async (rawUrl: string) => { + if (!rawUrl) return; if (modelsCount >= maxModels) { toast.error(`Limite de ${maxModels} peças simultâneas atingido. Remova uma antes de adicionar outra.`); - return false; + return; } - return true; - }, [modelsCount, maxModels]); - - // ============= MODO ARQUIVO ============= - const handleLoadFile = useCallback(async (rawUrl: string) => { - if (!rawUrl || !ensureCanAdd()) return; setLoading(true); try { const fileName = rawUrl.split('/').pop()?.split('?')[0] || 'modelo-cloud'; - if (!getSupportedExtension(fileName)) { + const ext = getSupportedExtension(fileName); + if (!ext) { toast.error('URL deve apontar para .GLB, .OBJ, .STL ou .IFC'); return; } + const response = await smartFetch(rawUrl); const buffer = await response.arrayBuffer(); - const outName = await importBuffer(buffer, fileName); + + let blob: Blob, outName = fileName, outSize = buffer.byteLength; + if (ext === 'glb') { + blob = new Blob([buffer], { type: 'model/gltf-binary' }); + } else if (ext === 'ifc') { + const r = await convertIFCtoGLB(buffer, fileName); + blob = r.blob; outName = r.fileName; outSize = r.fileSize; + } else { + const r = await convertToGLB(buffer, ext, fileName); + blob = r.blob; outName = r.fileName; outSize = r.fileSize; + } + const blobUrl = URL.createObjectURL(blob); + addModel({ fileName: outName, fileSize: outSize, url: blobUrl }); toast.success(`"${outName}" adicionado à cena`); setOpen(false); } catch (err) { @@ -145,57 +138,7 @@ export function CloudLoader({ compact = false, variant = 'outline', className }: } finally { setLoading(false); } - }, [ensureCanAdd, importBuffer]); - - // ============= MODO PASTA ============= - const openFolder = useCallback(async (folderUrl: string) => { - if (!folderUrl) return; - setBrowsingLoading(true); - setSelectedName(null); - try { - const result = await listFolder({ folderUrl }); - setListing(result); - } catch (err) { - console.error(err); - setListing(null); - toast.error(err instanceof Error ? err.message : 'Falha ao abrir pasta'); - } finally { - setBrowsingLoading(false); - } - }, []); - - const navigateTo = useCallback(async (path: string) => { - if (!listing) return; - setBrowsingLoading(true); - setSelectedName(null); - try { - const result = await listFolder({ origin: listing.origin, path }); - setListing(result); - } catch (err) { - console.error(err); - toast.error(err instanceof Error ? err.message : 'Falha ao navegar'); - } finally { - setBrowsingLoading(false); - } - }, [listing]); - - const handleLoadSelected = useCallback(async () => { - if (!listing || !selectedName || !ensureCanAdd()) return; - setLoading(true); - try { - const rawUrl = buildRawUrl(listing.origin, listing.path, selectedName, listing.token); - const response = await smartFetch(rawUrl); - const buffer = await response.arrayBuffer(); - const outName = await importBuffer(buffer, selectedName); - toast.success(`"${outName}" adicionado à cena`); - setOpen(false); - } catch (err) { - console.error(err); - toast.error(err instanceof Error ? err.message : 'Falha ao carregar arquivo'); - } finally { - setLoading(false); - } - }, [listing, selectedName, ensureCanAdd, importBuffer]); + }, [addModel, modelsCount, maxModels]); return ( @@ -215,127 +158,61 @@ export function CloudLoader({ compact = false, variant = 'outline', className }: )} - + Carregar modelo da nuvem - Aceita URL direta de arquivo ou URL de pasta (FileBrowser). + URL direta para .GLB, .OBJ, .STL ou .IFC. Dropbox use ?dl=1, OneDrive ?download=1. + FileBrowser é detectado automaticamente. - setTab(v as 'file' | 'folder')} className="w-full"> - - - Arquivo - - - Pasta - - - - {/* ============ TAB ARQUIVO ============ */} - -
- -
- - setUrl(e.target.value)} - placeholder="https://exemplo.com/modelo.glb" - disabled={loading} - className="font-mono text-xs" - /> -
-

- Dropbox use ?dl=1, OneDrive ?download=1. FileBrowser é detectado automaticamente. -

-
- - {PRESETS.length > 0 && ( -
- -
- {PRESETS.map((p) => ( - - ))} -
-
- )} -
- - {/* ============ TAB PASTA ============ */} - -
- -
- setFolderInput(e.target.value)} - placeholder="https://store.exemplo.com/modelos/" - disabled={browsingLoading || loading} - className="font-mono text-xs" - /> - -
-
- - {listing && ( - +
+ +
+ + setUrl(e.target.value)} + placeholder="https://exemplo.com/modelo.glb" + disabled={loading} + className="font-mono text-xs" /> - )} - - +
+
+ + {PRESETS.length > 0 && ( +
+ +
+ {PRESETS.map((p) => ( + + ))} +
+
+ )} + - {tab === 'file' ? ( - - ) : ( - - )} +
diff --git a/src/components/FolderBrowser.tsx b/src/components/FolderBrowser.tsx deleted file mode 100644 index 1305976..0000000 --- a/src/components/FolderBrowser.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index fdb4158..0000000 --- a/src/lib/filebrowser.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * 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`; -}