Adicionou card Recentes na tela
X-Lovable-Edit-ID: edt-f929450e-a9d7-48a8-8123-0bba03047ace Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,7 @@ 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 { validateModelFile } from '@/lib/validateModelFile';
|
import { validateModelFile } from '@/lib/validateModelFile';
|
||||||
|
import { addRecentFile } from '@/lib/recentFiles';
|
||||||
|
|
||||||
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
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);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
addModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
addModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
||||||
|
addRecentFile(outName, blob).catch(() => {});
|
||||||
toast.success(`"${outName}" adicionado à cena`);
|
toast.success(`"${outName}" adicionado à cena`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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<RecentFileMeta[]>([]);
|
||||||
|
const [loadingId, setLoadingId] = useState<string | null>(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 (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-3 text-center">
|
||||||
|
<Clock className="mx-auto mb-1 h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
Nenhum arquivo recente — os modelos carregados aparecerão aqui
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3 w-3 text-primary" />
|
||||||
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Recentes
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-[11px] text-muted-foreground">
|
||||||
|
{items.length}/6
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((r) => {
|
||||||
|
const isLoading = loadingId === r.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border bg-muted/30 p-2 transition-colors cursor-pointer hover:bg-muted/50 hover:border-primary/50"
|
||||||
|
onClick={() => !isLoading && handleLoad(r)}
|
||||||
|
title="Clique para carregar"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? <Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
|
||||||
|
: <FileBox className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-mono text-[11px] text-foreground">{r.fileName}</p>
|
||||||
|
<p className="font-mono text-[9px] text-muted-foreground">
|
||||||
|
{formatSize(r.fileSize)} · {formatWhen(r.addedAt)} atrás
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 shrink-0"
|
||||||
|
onClick={(e) => handleRemove(e, r)}
|
||||||
|
title="Remover dos recentes"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<IDBDatabase> {
|
||||||
|
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<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
await new Promise<void>((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<Blob | undefined> {
|
||||||
|
const db = await openDB();
|
||||||
|
const blob = await new Promise<Blob | undefined>((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<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
await new Promise<void>((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<void> {
|
||||||
|
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<void> {
|
||||||
|
const list = listRecentFiles().filter(r => r.id !== id);
|
||||||
|
saveMeta(list);
|
||||||
|
try { await idbDelete(id); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearRecentFiles(): Promise<void> {
|
||||||
|
const list = listRecentFiles();
|
||||||
|
for (const r of list) { try { await idbDelete(r.id); } catch {} }
|
||||||
|
saveMeta([]);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { convertIFCtoGLB } from '@/lib/convertIFC';
|
|||||||
import { validateModelFile } from '@/lib/validateModelFile';
|
import { validateModelFile } from '@/lib/validateModelFile';
|
||||||
import { CloudLoader } from '@/components/CloudLoader';
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
import { SceneModelList } from '@/components/SceneModelList';
|
import { SceneModelList } from '@/components/SceneModelList';
|
||||||
|
import { RecentFilesList } from '@/components/RecentFilesList';
|
||||||
|
import { addRecentFile } from '@/lib/recentFiles';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -59,6 +61,7 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
addModel({ fileName: file.name, fileSize: file.size, url });
|
addModel({ fileName: file.name, fileSize: file.size, url });
|
||||||
|
addRecentFile(file.name, file).catch(() => {});
|
||||||
toast.success(`Modelo "${file.name}" carregado com sucesso!`);
|
toast.success(`Modelo "${file.name}" carregado com sucesso!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,7 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
const url = URL.createObjectURL(result.blob);
|
const url = URL.createObjectURL(result.blob);
|
||||||
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
||||||
|
addRecentFile(result.fileName, result.blob).catch(() => {});
|
||||||
toast.success(`"${file.name}" convertido para GLB e carregado!`);
|
toast.success(`"${file.name}" convertido para GLB e carregado!`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[upload] conversion failed', err);
|
console.error('[upload] conversion failed', err);
|
||||||
@@ -101,6 +105,7 @@ const Index = () => {
|
|||||||
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
|
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
|
||||||
const url = URL.createObjectURL(result.blob);
|
const url = URL.createObjectURL(result.blob);
|
||||||
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
||||||
|
addRecentFile(result.fileName, result.blob).catch(() => {});
|
||||||
toast.success('Modelo demo "250 HP" (IFC) carregado!');
|
toast.success('Modelo demo "250 HP" (IFC) carregado!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -118,6 +123,7 @@ const Index = () => {
|
|||||||
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
|
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
|
||||||
const url = URL.createObjectURL(result.blob);
|
const url = URL.createObjectURL(result.blob);
|
||||||
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
||||||
|
addRecentFile(result.fileName, result.blob).catch(() => {});
|
||||||
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
|
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -209,6 +215,12 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Recent files card — últimos 6 modelos abertos (cache local) */}
|
||||||
|
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||||
|
<RecentFilesList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Enter viewer */}
|
{/* Enter viewer */}
|
||||||
<Button
|
<Button
|
||||||
className="h-14 w-full gap-3 text-base font-semibold glow-primary"
|
className="h-14 w-full gap-3 text-base font-semibold glow-primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user