Reverted to commit 85969433e6
This commit is contained in:
+52
-42
@@ -1,52 +1,62 @@
|
|||||||
# Navegador de pasta na nuvem (FileBrowser)
|
## Resposta curta
|
||||||
|
|
||||||
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**.
|
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.
|
||||||
|
|
||||||
## Como vai funcionar (UX)
|
## O que muda
|
||||||
|
|
||||||
1. No diálogo "Carregar modelo da nuvem" passa a haver dois modos, lado a lado:
|
### 1. Store (`useModelStore.ts`)
|
||||||
- **Arquivo** (comportamento atual) — cola URL do arquivo, clica Carregar.
|
- Substituir `model: ModelInfo | null` por `models: SceneModel[]` (até 5 simultâneos para manter 72 FPS no Quest 3).
|
||||||
- **Pasta** (novo) — cola URL da pasta, clica **Abrir pasta**.
|
- Cada `SceneModel` tem: `id`, `fileName`, `url`, `fineTuning` próprio, `visible`, `color` (cor de identificação), `locked`.
|
||||||
2. Ao abrir uma pasta:
|
- Adicionar `activeModelId: string | null` — o "modelo selecionado" recebe os controles de fine-tuning, escala e medição.
|
||||||
- Mostra breadcrumb do caminho atual (clicável para subir).
|
- Ações: `addModel`, `removeModel`, `duplicateModel`, `setActiveModel`, `updateModelTransform`, `toggleVisibility`.
|
||||||
- Lista subpastas (📁) e arquivos 3D suportados (📄 com extensão e tamanho).
|
- Compatibilidade: `model` (singular) continua exposto como getter derivado do `activeModelId` para não quebrar telas legadas.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Detecção e API (FileBrowser)
|
### 2. Cena 3D (`ModelViewer.tsx`)
|
||||||
|
- Renderizar `models.map(m => <GLBModel key={m.id} model={m} isActive={m.id === activeModelId} />)`.
|
||||||
|
- Cada `<GLBModel>` 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).
|
||||||
|
|
||||||
FileBrowser expõe `GET /api/resources/{caminho}?auth=<JWT>` que devolve JSON com `items: [{ name, isDir, size, extension, ... }]`. Vou:
|
### 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.
|
||||||
|
|
||||||
- Reaproveitar `tryFileBrowserToken(origin)` (NoAuth) já implementado.
|
### 4. UI XR/AR (`XRHud.tsx`)
|
||||||
- A partir da URL colada, derivar `origin` + `path` (path da pasta, com `/` final).
|
- Painel flutuante com a lista de peças (mesmos botões compactos).
|
||||||
- Chamar `/api/resources{path}?auth=<token>` e renderizar `items`.
|
- Botão "Adicionar" abre o `CloudLoader` já existente em modo append.
|
||||||
- Para baixar o arquivo escolhido, montar `…/api/raw{path}/{name}?auth=<token>` (mesma lógica do `smartFetch`).
|
- Gestos do controle: o grab (já implementado em `XRGrabbable`) move só o modelo ativo / o que foi pinçado diretamente.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Escopo desta entrega
|
### 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.
|
||||||
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).
|
- **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.
|
||||||
## Arquivos
|
- **Compare mode**: opera sobre a cena toda, sem mudança.
|
||||||
|
|
||||||
- **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
|
## Limitações conhecidas
|
||||||
|
|
||||||
- Funciona somente em FileBrowser com NoAuth ou já autenticado pelo navegador.
|
- **Performance**: GLB grandes somados podem derrubar FPS no Quest 3. Vou impor limite de **5 modelos simultâneos** com aviso.
|
||||||
- Servidor precisa permitir CORS para o domínio do app (já é o caso de `store.tracksteel.com.br`).
|
- **Conversão IFC**: cada arquivo passa pelo conversor cliente (`convertIFC.ts`) já existente — sem mudança.
|
||||||
- Listagens muito grandes (>500 arquivos) podem ficar lentas; sem paginação nesta versão.
|
- **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
|
||||||
|
|||||||
+86
-209
@@ -1,23 +1,17 @@
|
|||||||
import { useState, useCallback } from 'react';
|
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 { 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 },
|
||||||
@@ -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.). */
|
/** 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());
|
||||||
@@ -53,12 +61,17 @@ 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 */ }
|
||||||
|
|
||||||
const token = await getFileBrowserToken(u.origin);
|
// 2ª tentativa: pega token JWT NoAuth e usa ?auth=
|
||||||
|
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' });
|
||||||
@@ -72,7 +85,9 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -81,62 +96,40 @@ 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 [tab, setTab] = useState<'file' | 'folder'>('file');
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// ---- modo Arquivo
|
const handleLoad = useCallback(async (rawUrl: string) => {
|
||||||
const [url, setUrl] = useState(DEFAULT_URL);
|
if (!rawUrl) return;
|
||||||
|
|
||||||
// ---- 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(() => {
|
|
||||||
if (modelsCount >= maxModels) {
|
if (modelsCount >= maxModels) {
|
||||||
toast.error(`Limite de ${maxModels} peças simultâneas atingido. Remova uma antes de adicionar outra.`);
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const fileName = rawUrl.split('/').pop()?.split('?')[0] || 'modelo-cloud';
|
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');
|
toast.error('URL deve apontar para .GLB, .OBJ, .STL ou .IFC');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await smartFetch(rawUrl);
|
const response = await smartFetch(rawUrl);
|
||||||
const buffer = await response.arrayBuffer();
|
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`);
|
toast.success(`"${outName}" adicionado à cena`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -145,57 +138,7 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [ensureCanAdd, importBuffer]);
|
}, [addModel, modelsCount, maxModels]);
|
||||||
|
|
||||||
// ============= 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}>
|
||||||
@@ -215,127 +158,61 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-md">
|
||||||
<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">
|
||||||
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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as 'file' | 'folder')} className="w-full">
|
<div className="space-y-3">
|
||||||
<TabsList className="grid grid-cols-2 w-full">
|
<div className="space-y-1.5">
|
||||||
<TabsTrigger value="file" className="text-xs font-mono gap-1.5">
|
<Label htmlFor="cloud-url" className="text-xs font-mono">URL do modelo</Label>
|
||||||
<Link2 className="h-3 w-3" /> Arquivo
|
<div className="flex gap-2">
|
||||||
</TabsTrigger>
|
<Link2 className="h-4 w-4 text-muted-foreground mt-2.5 shrink-0" />
|
||||||
<TabsTrigger value="folder" className="text-xs font-mono gap-1.5">
|
<Input
|
||||||
<FolderOpen className="h-3 w-3" /> Pasta
|
id="cloud-url"
|
||||||
</TabsTrigger>
|
value={url}
|
||||||
</TabsList>
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://exemplo.com/modelo.glb"
|
||||||
{/* ============ TAB ARQUIVO ============ */}
|
disabled={loading}
|
||||||
<TabsContent value="file" className="space-y-3 mt-3">
|
className="font-mono text-xs"
|
||||||
<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>
|
||||||
</TabsContent>
|
</div>
|
||||||
</Tabs>
|
|
||||||
|
{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>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>Cancelar</Button>
|
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>Cancelar</Button>
|
||||||
{tab === 'file' ? (
|
<Button onClick={() => handleLoad(url.trim())} disabled={loading || !url.trim()} className="gap-2">
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user