From dd7e1248fc6aaf58de032b99c030a3c5e38dab2e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 16:55:34 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- .lovable/plan.md | 94 ++++++++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 52 deletions(-) diff --git a/.lovable/plan.md b/.lovable/plan.md index 649cb92..59127ba 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,62 +1,52 @@ -## Resposta curta +# Navegador de pasta na nuvem (FileBrowser) -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. +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**. -## O que muda +## Como vai funcionar (UX) -### 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. +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. -### 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). +## Detecção e API (FileBrowser) -### 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. +FileBrowser expõe `GET /api/resources/{caminho}?auth=` que devolve JSON com `items: [{ name, isDir, size, extension, ... }]`. Vou: -### 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. +- 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. -### 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. +## 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. ## Limitações conhecidas -- **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 +- 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. From 09e5ecfb03fc10f90725faf6c123889b7d4ea4cf Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 16:56:16 +0000 Subject: [PATCH 2/3] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/FolderBrowser.tsx | 126 +++++++++++++++++++++++++++++ src/lib/filebrowser.ts | 133 +++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/components/FolderBrowser.tsx create mode 100644 src/lib/filebrowser.ts 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`; +} From e2009dd37b71e4f3d0cff5ff5bd7ff52c51a17f0 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 16:57:12 +0000 Subject: [PATCH 3/3] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/CloudLoader.tsx | 289 +++++++++++++++++++++++---------- 1 file changed, 206 insertions(+), 83 deletions(-) diff --git a/src/components/CloudLoader.tsx b/src/components/CloudLoader.tsx index d8af547..69e1651 100644 --- a/src/components/CloudLoader.tsx +++ b/src/components/CloudLoader.tsx @@ -1,17 +1,23 @@ import { useState, useCallback } from 'react'; -import { Cloud, Loader2, Link2 } from 'lucide-react'; +import { Cloud, Loader2, Link2, FolderOpen } 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 }, @@ -35,20 +41,6 @@ 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()); @@ -61,17 +53,12 @@ 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 */ } - // 2ª tentativa: pega token JWT NoAuth e usa ?auth= - const token = await tryFileBrowserToken(u.origin); + const token = await getFileBrowserToken(u.origin); if (token) { const sep = u.search ? '&' : '?'; const r = await fetch(`${u.origin}${path}${u.search}${sep}auth=${encodeURIComponent(token)}`, { mode: 'cors' }); @@ -85,9 +72,7 @@ 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; } @@ -96,40 +81,62 @@ 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 [url, setUrl] = useState(DEFAULT_URL); + const [tab, setTab] = useState<'file' | 'folder'>('file'); const [loading, setLoading] = useState(false); - const handleLoad = useCallback(async (rawUrl: string) => { - if (!rawUrl) return; + // ---- 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(() => { if (modelsCount >= maxModels) { toast.error(`Limite de ${maxModels} peças simultâneas atingido. Remova uma antes de adicionar outra.`); - return; + return false; } + 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'; - const ext = getSupportedExtension(fileName); - if (!ext) { + if (!getSupportedExtension(fileName)) { toast.error('URL deve apontar para .GLB, .OBJ, .STL ou .IFC'); return; } - const response = await smartFetch(rawUrl); const buffer = await response.arrayBuffer(); - - 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 }); + const outName = await importBuffer(buffer, fileName); toast.success(`"${outName}" adicionado à cena`); setOpen(false); } catch (err) { @@ -138,7 +145,57 @@ export function CloudLoader({ compact = false, variant = 'outline', className }: } finally { setLoading(false); } - }, [addModel, modelsCount, maxModels]); + }, [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]); return ( @@ -158,61 +215,127 @@ export function CloudLoader({ compact = false, variant = 'outline', className }: )} - + Carregar modelo da nuvem - URL direta para .GLB, .OBJ, .STL ou .IFC. Dropbox use ?dl=1, OneDrive ?download=1. - FileBrowser é detectado automaticamente. + Aceita URL direta de arquivo ou URL de pasta (FileBrowser). -
-
- -
- - setUrl(e.target.value)} - placeholder="https://exemplo.com/modelo.glb" - disabled={loading} - className="font-mono text-xs" - /> -
-
+ setTab(v as 'file' | 'folder')} className="w-full"> + + + Arquivo + + + Pasta + + - {PRESETS.length > 0 && ( + {/* ============ TAB ARQUIVO ============ */} +
- -
- {PRESETS.map((p) => ( - - ))} + +
+ + 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 && ( + + )} +
+
- + {tab === 'file' ? ( + + ) : ( + + )}