Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+169
-46
@@ -1,17 +1,23 @@
|
|||||||
import { useState, useCallback } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB';
|
import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB';
|
||||||
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
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_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
||||||
|
const DEFAULT_FOLDER = 'https://store.tracksteel.com.br/';
|
||||||
|
|
||||||
const PRESETS: { label: string; url: string }[] = [
|
const PRESETS: { label: string; url: string }[] = [
|
||||||
{ label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL },
|
{ 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<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.). */
|
/** Faz fetch tentando vários endpoints típicos de hosts que servem SPA (FileBrowser etc.). */
|
||||||
async function smartFetch(rawUrl: string): Promise<Response> {
|
async function smartFetch(rawUrl: string): Promise<Response> {
|
||||||
const url = normalizeCloudUrl(rawUrl.trim());
|
const url = normalizeCloudUrl(rawUrl.trim());
|
||||||
@@ -61,17 +53,12 @@ async function smartFetch(rawUrl: string): Promise<Response> {
|
|||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
const path = u.pathname.startsWith('/api/raw/') ? u.pathname : `/api/raw${u.pathname}`;
|
const path = u.pathname.startsWith('/api/raw/') ? u.pathname : `/api/raw${u.pathname}`;
|
||||||
|
|
||||||
// 1ª tentativa: /api/raw/... cru
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${u.origin}${path}${u.search}`, { mode: 'cors' });
|
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.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 */ }
|
} catch { /* continua */ }
|
||||||
|
|
||||||
// 2ª tentativa: pega token JWT NoAuth e usa ?auth=
|
const token = await getFileBrowserToken(u.origin);
|
||||||
const token = await tryFileBrowserToken(u.origin);
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const sep = u.search ? '&' : '?';
|
const sep = u.search ? '&' : '?';
|
||||||
const r = await fetch(`${u.origin}${path}${u.search}${sep}auth=${encodeURIComponent(token)}`, { mode: 'cors' });
|
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<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CloudLoaderProps {
|
interface CloudLoaderProps {
|
||||||
/** Renderiza como botão compacto (para HUD do AR) */
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** Variante visual do botão */
|
|
||||||
variant?: 'default' | 'outline';
|
variant?: 'default' | 'outline';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -96,27 +81,25 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
const addModel = useModelStore((s) => s.addModel);
|
const addModel = useModelStore((s) => s.addModel);
|
||||||
const modelsCount = useModelStore((s) => s.models.length);
|
const modelsCount = useModelStore((s) => s.models.length);
|
||||||
const maxModels = useModelStore((s) => s.maxModels);
|
const maxModels = useModelStore((s) => s.maxModels);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [url, setUrl] = useState(DEFAULT_URL);
|
const [tab, setTab] = useState<'file' | 'folder'>('file');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleLoad = useCallback(async (rawUrl: string) => {
|
// ---- modo Arquivo
|
||||||
if (!rawUrl) return;
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
if (modelsCount >= maxModels) {
|
|
||||||
toast.error(`Limite de ${maxModels} peças simultâneas atingido. Remova uma antes de adicionar outra.`);
|
|
||||||
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);
|
// ---- modo Pasta
|
||||||
const buffer = await response.arrayBuffer();
|
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;
|
let blob: Blob, outName = fileName, outSize = buffer.byteLength;
|
||||||
if (ext === 'glb') {
|
if (ext === 'glb') {
|
||||||
@@ -130,6 +113,30 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
}
|
}
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
addModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
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 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';
|
||||||
|
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();
|
||||||
|
const outName = await importBuffer(buffer, fileName);
|
||||||
toast.success(`"${outName}" adicionado à cena`);
|
toast.success(`"${outName}" adicionado à cena`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -138,7 +145,57 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -158,19 +215,29 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Cloud className="h-4 w-4 text-primary" />
|
<Cloud className="h-4 w-4 text-primary" />
|
||||||
Carregar modelo da nuvem
|
Carregar modelo da nuvem
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
URL direta para .GLB, .OBJ, .STL ou .IFC. Dropbox use <code>?dl=1</code>, OneDrive <code>?download=1</code>.
|
Aceita URL direta de arquivo ou URL de pasta (FileBrowser).
|
||||||
FileBrowser é detectado automaticamente.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cloud-url" className="text-xs font-mono">URL do modelo</Label>
|
<Label htmlFor="cloud-url" className="text-xs font-mono">URL do modelo</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -184,6 +251,9 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Dropbox use <code>?dl=1</code>, OneDrive <code>?download=1</code>. FileBrowser é detectado automaticamente.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{PRESETS.length > 0 && (
|
{PRESETS.length > 0 && (
|
||||||
@@ -205,14 +275,67 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listing && (
|
||||||
|
<FolderBrowser
|
||||||
|
origin={listing.origin}
|
||||||
|
path={listing.path}
|
||||||
|
items={listing.items}
|
||||||
|
loading={browsingLoading}
|
||||||
|
selectedName={selectedName}
|
||||||
|
showAll={showAll}
|
||||||
|
onToggleShowAll={setShowAll}
|
||||||
|
onNavigate={navigateTo}
|
||||||
|
onSelect={setSelectedName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>Cancelar</Button>
|
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>Cancelar</Button>
|
||||||
<Button onClick={() => handleLoad(url.trim())} disabled={loading || !url.trim()} className="gap-2">
|
{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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
|
||||||
{loading ? 'Baixando…' : 'Carregar'}
|
{loading ? 'Baixando…' : 'Carregar'}
|
||||||
</Button>
|
</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>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user