Reverted to commit 85969433e6

This commit is contained in:
gpt-engineer-app[bot]
2026-05-14 17:16:27 +00:00
parent a56d1f5751
commit 649eba7bfa
4 changed files with 138 additions and 510 deletions
+86 -209
View File
@@ -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<string | null> {
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<Response> {
const url = normalizeCloudUrl(rawUrl.trim());
@@ -53,12 +61,17 @@ async function smartFetch(rawUrl: string): Promise<Response> {
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<Response> {
}
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<FolderListing | null>(null);
const [browsingLoading, setBrowsingLoading] = useState(false);
const [selectedName, setSelectedName] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
@@ -215,127 +158,61 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-primary" />
Carregar modelo da nuvem
</DialogTitle>
<DialogDescription className="text-xs">
Aceita URL direta de arquivo ou URL de pasta (FileBrowser).
URL direta para .GLB, .OBJ, .STL ou .IFC. Dropbox use <code>?dl=1</code>, OneDrive <code>?download=1</code>.
FileBrowser é detectado automaticamente.
</DialogDescription>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as 'file' | 'folder')} className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="file" className="text-xs font-mono gap-1.5">
<Link2 className="h-3 w-3" /> Arquivo
</TabsTrigger>
<TabsTrigger value="folder" className="text-xs font-mono gap-1.5">
<FolderOpen className="h-3 w-3" /> Pasta
</TabsTrigger>
</TabsList>
{/* ============ TAB ARQUIVO ============ */}
<TabsContent value="file" className="space-y-3 mt-3">
<div className="space-y-1.5">
<Label htmlFor="cloud-url" className="text-xs font-mono">URL do modelo</Label>
<div className="flex gap-2">
<Link2 className="h-4 w-4 text-muted-foreground mt-2.5 shrink-0" />
<Input
id="cloud-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://exemplo.com/modelo.glb"
disabled={loading}
className="font-mono text-xs"
/>
</div>
<p className="text-[10px] text-muted-foreground">
Dropbox use <code>?dl=1</code>, OneDrive <code>?download=1</code>. FileBrowser é detectado automaticamente.
</p>
</div>
{PRESETS.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs font-mono text-muted-foreground">Templates prontos</Label>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<Button
key={p.url}
variant="ghost"
size="sm"
className="h-7 text-[10px] font-mono border border-border"
disabled={loading}
onClick={() => setUrl(p.url)}
>
{p.label}
</Button>
))}
</div>
</div>
)}
</TabsContent>
{/* ============ TAB PASTA ============ */}
<TabsContent value="folder" className="space-y-3 mt-3">
<div className="space-y-1.5">
<Label htmlFor="folder-url" className="text-xs font-mono">URL da pasta (FileBrowser)</Label>
<div className="flex gap-2">
<Input
id="folder-url"
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder="https://store.exemplo.com/modelos/"
disabled={browsingLoading || loading}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => openFolder(folderInput.trim())}
disabled={browsingLoading || loading || !folderInput.trim()}
className="gap-1.5 text-xs"
>
{browsingLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderOpen className="h-3.5 w-3.5" />}
Abrir
</Button>
</div>
</div>
{listing && (
<FolderBrowser
origin={listing.origin}
path={listing.path}
items={listing.items}
loading={browsingLoading}
selectedName={selectedName}
showAll={showAll}
onToggleShowAll={setShowAll}
onNavigate={navigateTo}
onSelect={setSelectedName}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="cloud-url" className="text-xs font-mono">URL do modelo</Label>
<div className="flex gap-2">
<Link2 className="h-4 w-4 text-muted-foreground mt-2.5 shrink-0" />
<Input
id="cloud-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://exemplo.com/modelo.glb"
disabled={loading}
className="font-mono text-xs"
/>
)}
</TabsContent>
</Tabs>
</div>
</div>
{PRESETS.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs font-mono text-muted-foreground">Templates prontos</Label>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<Button
key={p.url}
variant="ghost"
size="sm"
className="h-7 text-[10px] font-mono border border-border"
disabled={loading}
onClick={() => setUrl(p.url)}
>
{p.label}
</Button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>Cancelar</Button>
{tab === 'file' ? (
<Button onClick={() => handleLoadFile(url.trim())} disabled={loading || !url.trim()} className="gap-2">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
{loading ? 'Baixando…' : 'Carregar'}
</Button>
) : (
<Button
onClick={handleLoadSelected}
disabled={loading || !selectedName}
className="gap-2"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
{loading ? 'Baixando…' : selectedName ? `Carregar "${selectedName}"` : 'Selecione um arquivo'}
</Button>
)}
<Button onClick={() => handleLoad(url.trim())} disabled={loading || !url.trim()} className="gap-2">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
{loading ? 'Baixando…' : 'Carregar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
-126
View File
@@ -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 (
<div className="space-y-2">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted/30 px-2 py-1.5 rounded border border-border overflow-x-auto">
<span className="opacity-60 truncate max-w-[120px]" title={origin}>{new URL(origin).hostname}</span>
<ChevronRight className="h-3 w-3 shrink-0" />
<button
className="hover:text-primary flex items-center gap-1 shrink-0"
onClick={() => onNavigate('/')}
disabled={loading}
>
<Home className="h-3 w-3" /> root
</button>
{crumbs.map((c, i) => (
<span key={c.path} className="flex items-center gap-1 shrink-0">
<ChevronRight className="h-3 w-3" />
<button
className={cn('hover:text-primary', i === crumbs.length - 1 && 'text-primary font-bold')}
onClick={() => onNavigate(c.path)}
disabled={loading}
>
{c.name}
</button>
</span>
))}
</div>
{/* Toolbar */}
<div className="flex items-center justify-between text-[10px] font-mono">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-[10px]"
onClick={() => onNavigate(parentPath(path))}
disabled={loading || path === '/'}
>
<ArrowUp className="h-3 w-3" /> Subir
</Button>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={showAll}
onChange={(e) => onToggleShowAll(e.target.checked)}
disabled={loading}
className="h-3 w-3"
/>
<span className="text-muted-foreground">Mostrar todos</span>
</label>
</div>
{/* Listagem */}
<ScrollArea className="h-64 border border-border rounded">
{loading ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-xs font-mono">Carregando</span>
</div>
) : visible.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground text-xs font-mono">
Pasta vazia ou sem arquivos suportados
</div>
) : (
<div className="divide-y divide-border">
{dirs.map((it) => (
<button
key={`d-${it.name}`}
onClick={() => onNavigate(`${path}${it.name}/`)}
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 text-left text-xs font-mono"
>
<Folder className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="truncate flex-1">{it.name}</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
</button>
))}
{files.map((it) => {
const supported = isSupportedModel(it);
const selected = selectedName === it.name;
return (
<button
key={`f-${it.name}`}
onClick={() => supported && onSelect(it.name)}
disabled={!supported}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs font-mono',
supported ? 'hover:bg-muted/40 cursor-pointer' : 'opacity-40 cursor-not-allowed',
selected && 'bg-primary/15 ring-1 ring-primary',
)}
>
<FileBox className={cn('h-3.5 w-3.5 shrink-0', supported ? 'text-accent' : 'text-muted-foreground')} />
<span className="truncate flex-1">{it.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{formatBytes(it.size)}</span>
</button>
);
})}
</div>
)}
</ScrollArea>
</div>
);
}
-133
View File
@@ -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<string | null> {
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<FolderListing> {
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/<path>
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`;
}