Files
SteelXR2/src/components/CloudLoader.tsx
T
gpt-engineer-app[bot] 10d21f42c9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-14 11:58:09 +00:00

215 lines
8.0 KiB
TypeScript

import { useState, useCallback } from '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 { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB';
import { convertIFCtoGLB } from '@/lib/convertIFC';
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
const PRESETS: { label: string; url: string }[] = [
{ label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL },
];
/** Normaliza URLs de Dropbox / OneDrive para apontar ao binário direto. */
function normalizeCloudUrl(input: string): string {
try {
const u = new URL(input);
if (/(^|\.)dropbox\.com$/.test(u.hostname)) {
u.searchParams.set('dl', '1');
return u.toString();
}
if (/(^|\.)1drv\.ms$|onedrive\.live\.com$/.test(u.hostname)) {
u.searchParams.set('download', '1');
return u.toString();
}
return input;
} catch {
return input;
}
}
/** 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());
let res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
if (!ct.includes('text/html')) return res;
// Servidor devolveu HTML — tentamos endpoints binários do FileBrowser
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);
if (token) {
const sep = u.search ? '&' : '?';
const r = await fetch(`${u.origin}${path}${u.search}${sep}auth=${encodeURIComponent(token)}`, { mode: 'cors' });
if (r.ok && !(r.headers.get('content-type') || '').includes('text/html')) return r;
}
throw new Error(
'O servidor devolveu HTML. Use um link DIRETO do arquivo (ex.: Dropbox ?dl=1, OneDrive ?download=1, ' +
'ou habilite endpoint público no FileBrowser).'
);
}
interface CloudLoaderProps {
/** Renderiza como botão compacto (para HUD do AR) */
compact?: boolean;
/** Variante visual do botão */
variant?: 'default' | 'outline';
className?: string;
}
export function CloudLoader({ compact = false, variant = 'outline', className }: CloudLoaderProps) {
const setModel = useModelStore((s) => s.setModel);
const [open, setOpen] = useState(false);
const [url, setUrl] = useState(DEFAULT_URL);
const [loading, setLoading] = useState(false);
const handleLoad = useCallback(async (rawUrl: string) => {
if (!rawUrl) return;
setLoading(true);
try {
const fileName = rawUrl.split('/').pop()?.split('?')[0] || 'modelo-cloud';
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();
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);
setModel({ fileName: outName, fileSize: outSize, url: blobUrl });
toast.success(`"${outName}" carregado da nuvem`);
setOpen(false);
} catch (err) {
console.error(err);
toast.error(err instanceof Error ? err.message : 'Falha ao carregar da nuvem (verifique URL/CORS)');
} finally {
setLoading(false);
}
}, [setModel]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{compact ? (
<Button variant={variant} size="sm" className={`h-8 gap-1.5 text-[10px] font-mono ${className ?? ''}`} title="Carregar da Nuvem">
<Cloud className="h-3.5 w-3.5" />
Nuvem
</Button>
) : (
<Button
variant={variant}
className={`h-12 w-full gap-2 border-primary/40 text-primary hover:border-primary hover:bg-primary/10 transition-all text-xs ${className ?? ''}`}
>
<Cloud className="h-4 w-4" />
Carregar da Nuvem
</Button>
)}
</DialogTrigger>
<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">
URL direta para .GLB, .OBJ, .STL ou .IFC. Dropbox use <code>?dl=1</code>, OneDrive <code>?download=1</code>.
FileBrowser é detectado automaticamente.
</DialogDescription>
</DialogHeader>
<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"
/>
</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>
<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>
);
}