diff --git a/src/components/CloudLoader.tsx b/src/components/CloudLoader.tsx index e3e399f..30fbe11 100644 --- a/src/components/CloudLoader.tsx +++ b/src/components/CloudLoader.tsx @@ -11,6 +11,7 @@ import { toast } from 'sonner'; import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB'; import { convertIFCtoGLB } from '@/lib/convertIFC'; import { validateModelFile } from '@/lib/validateModelFile'; +import { addRecentFile } from '@/lib/recentFiles'; const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc'; @@ -139,6 +140,7 @@ export function CloudLoader({ compact = false, variant = 'outline', className }: } const blobUrl = URL.createObjectURL(blob); addModel({ fileName: outName, fileSize: outSize, url: blobUrl }); + addRecentFile(outName, blob).catch(() => {}); toast.success(`"${outName}" adicionado à cena`); setOpen(false); } catch (err) { diff --git a/src/components/RecentFilesList.tsx b/src/components/RecentFilesList.tsx new file mode 100644 index 0000000..62b18b3 --- /dev/null +++ b/src/components/RecentFilesList.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Clock, Trash2, Loader2, FileBox } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { listRecentFiles, loadRecentFile, removeRecentFile, type RecentFileMeta } from '@/lib/recentFiles'; +import { useModelStore } from '@/stores/useModelStore'; +import { toast } from 'sonner'; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatWhen(ts: number): string { + const diff = Date.now() - ts; + const m = Math.floor(diff / 60000); + if (m < 1) return 'agora'; + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} h`; + const d = Math.floor(h / 24); + return `${d} d`; +} + +export function RecentFilesList() { + const [items, setItems] = useState([]); + const [loadingId, setLoadingId] = useState(null); + const addModel = useModelStore((s) => s.addModel); + const models = useModelStore((s) => s.models); + const maxModels = useModelStore((s) => s.maxModels); + + const refresh = useCallback(() => setItems(listRecentFiles()), []); + + useEffect(() => { + refresh(); + const onStorage = (e: StorageEvent) => { + if (e.key === 'tsxr_recent_files_v1') refresh(); + }; + window.addEventListener('storage', onStorage); + // Polling leve para refletir adições no mesmo tab + const t = setInterval(refresh, 2000); + return () => { window.removeEventListener('storage', onStorage); clearInterval(t); }; + }, [refresh]); + + const handleLoad = useCallback(async (meta: RecentFileMeta) => { + if (models.length >= maxModels) { + toast.error(`Limite de ${maxModels} peças simultâneas atingido`); + return; + } + setLoadingId(meta.id); + try { + const res = await loadRecentFile(meta.id); + if (!res) { + toast.error('Arquivo recente não encontrado — pode ter sido limpo'); + await removeRecentFile(meta.id); + refresh(); + return; + } + addModel({ fileName: meta.fileName, fileSize: meta.fileSize, url: res.url }); + toast.success(`"${meta.fileName}" carregado dos recentes`); + } catch (e) { + console.error(e); + toast.error('Falha ao carregar arquivo recente'); + } finally { + setLoadingId(null); + } + }, [addModel, models.length, maxModels, refresh]); + + const handleRemove = useCallback(async (e: React.MouseEvent, meta: RecentFileMeta) => { + e.stopPropagation(); + await removeRecentFile(meta.id); + refresh(); + }, [refresh]); + + if (items.length === 0) { + return ( +
+ +

+ Nenhum arquivo recente — os modelos carregados aparecerão aqui +

+
+ ); + } + + return ( +
+
+ + + Recentes + + + {items.length}/6 + +
+
+ {items.map((r) => { + const isLoading = loadingId === r.id; + return ( +
!isLoading && handleLoad(r)} + title="Clique para carregar" + > + {isLoading + ? + : } +
+

{r.fileName}

+

+ {formatSize(r.fileSize)} · {formatWhen(r.addedAt)} atrás +

+
+ +
+ ); + })} +
+
+ ); +} diff --git a/src/lib/recentFiles.ts b/src/lib/recentFiles.ts new file mode 100644 index 0000000..3f1b92e --- /dev/null +++ b/src/lib/recentFiles.ts @@ -0,0 +1,122 @@ +// Armazena os arquivos GLB recentes (após conversão) para reuso rápido, +// sem necessidade de recarregar da nuvem ou do disco local. +// +// Estratégia: +// - Metadados (lista JSON ordenada) em localStorage → leitura síncrona / rápida +// - Blob real do GLB em IndexedDB → suporta arquivos grandes + +const META_KEY = 'tsxr_recent_files_v1'; +const DB_NAME = 'tsxr-recent'; +const STORE = 'glb'; +const MAX_RECENTS = 6; + +export interface RecentFileMeta { + id: string; // chave no IndexedDB + fileName: string; // nome do arquivo GLB + fileSize: number; // bytes + addedAt: number; // timestamp ms +} + +// ── IndexedDB helpers ───────────────────────────────────────────── +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idbPut(id: string, blob: Blob): Promise { + const db = await openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(blob, id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); +} + +async function idbGet(id: string): Promise { + const db = await openDB(); + const blob = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(id); + req.onsuccess = () => resolve(req.result as Blob | undefined); + req.onerror = () => reject(req.error); + }); + db.close(); + return blob; +} + +async function idbDelete(id: string): Promise { + const db = await openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); +} + +// ── Metadata helpers ───────────────────────────────────────────── +export function listRecentFiles(): RecentFileMeta[] { + try { + const raw = localStorage.getItem(META_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as RecentFileMeta[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveMeta(list: RecentFileMeta[]) { + try { localStorage.setItem(META_KEY, JSON.stringify(list)); } catch {} +} + +/** Registra um GLB nos recentes (substitui se mesmo fileName) e mantém apenas MAX_RECENTS. */ +export async function addRecentFile(fileName: string, blob: Blob): Promise { + try { + const list = listRecentFiles(); + const existing = list.find(r => r.fileName === fileName); + const id = existing?.id ?? `rf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + await idbPut(id, blob); + const filtered = list.filter(r => r.fileName !== fileName); + const next: RecentFileMeta[] = [ + { id, fileName, fileSize: blob.size, addedAt: Date.now() }, + ...filtered, + ].slice(0, MAX_RECENTS); + // Limpa blobs removidos + for (const r of filtered.slice(MAX_RECENTS - 1)) { + try { await idbDelete(r.id); } catch {} + } + saveMeta(next); + } catch (e) { + console.warn('[recentFiles] addRecent failed', e); + } +} + +/** Recupera o blob de um recente e devolve um object URL pronto para uso. */ +export async function loadRecentFile(id: string): Promise<{ url: string; blob: Blob } | null> { + const blob = await idbGet(id); + if (!blob) return null; + return { url: URL.createObjectURL(blob), blob }; +} + +export async function removeRecentFile(id: string): Promise { + const list = listRecentFiles().filter(r => r.id !== id); + saveMeta(list); + try { await idbDelete(id); } catch {} +} + +export async function clearRecentFiles(): Promise { + const list = listRecentFiles(); + for (const r of list) { try { await idbDelete(r.id); } catch {} } + saveMeta([]); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index e8018b7..f15d7a3 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -9,6 +9,8 @@ import { convertIFCtoGLB } from '@/lib/convertIFC'; import { validateModelFile } from '@/lib/validateModelFile'; import { CloudLoader } from '@/components/CloudLoader'; import { SceneModelList } from '@/components/SceneModelList'; +import { RecentFilesList } from '@/components/RecentFilesList'; +import { addRecentFile } from '@/lib/recentFiles'; const Index = () => { const navigate = useNavigate(); @@ -59,6 +61,7 @@ const Index = () => { } const url = URL.createObjectURL(file); addModel({ fileName: file.name, fileSize: file.size, url }); + addRecentFile(file.name, file).catch(() => {}); toast.success(`Modelo "${file.name}" carregado com sucesso!`); return; } @@ -81,6 +84,7 @@ const Index = () => { } const url = URL.createObjectURL(result.blob); addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); + addRecentFile(result.fileName, result.blob).catch(() => {}); toast.success(`"${file.name}" convertido para GLB e carregado!`); } catch (err) { console.error('[upload] conversion failed', err); @@ -101,6 +105,7 @@ const Index = () => { const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc'); const url = URL.createObjectURL(result.blob); addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); + addRecentFile(result.fileName, result.blob).catch(() => {}); toast.success('Modelo demo "250 HP" (IFC) carregado!'); } catch (err) { console.error(err); @@ -118,6 +123,7 @@ const Index = () => { const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc'); const url = URL.createObjectURL(result.blob); addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); + addRecentFile(result.fileName, result.blob).catch(() => {}); toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!'); } catch (err) { console.error(err); @@ -209,6 +215,12 @@ const Index = () => { )} + {/* Recent files card — últimos 6 modelos abertos (cache local) */} +
+ +
+ + {/* Enter viewer */}