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' ? ( + + ) : ( + + )}