From 09e5ecfb03fc10f90725faf6c123889b7d4ea4cf Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Thu, 14 May 2026 16:56:16 +0000
Subject: [PATCH] Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
---
src/components/FolderBrowser.tsx | 126 +++++++++++++++++++++++++++++
src/lib/filebrowser.ts | 133 +++++++++++++++++++++++++++++++
2 files changed, 259 insertions(+)
create mode 100644 src/components/FolderBrowser.tsx
create mode 100644 src/lib/filebrowser.ts
diff --git a/src/components/FolderBrowser.tsx b/src/components/FolderBrowser.tsx
new file mode 100644
index 0000000..1305976
--- /dev/null
+++ b/src/components/FolderBrowser.tsx
@@ -0,0 +1,126 @@
+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 (
+
+ {/* Breadcrumb */}
+
+ {new URL(origin).hostname}
+
+
+ {crumbs.map((c, i) => (
+
+
+
+
+ ))}
+
+
+ {/* Toolbar */}
+
+
+
+
+
+ {/* Listagem */}
+
+ {loading ? (
+
+
+ Carregando…
+
+ ) : visible.length === 0 ? (
+
+ Pasta vazia ou sem arquivos suportados
+
+ ) : (
+
+ {dirs.map((it) => (
+
+ ))}
+ {files.map((it) => {
+ const supported = isSupportedModel(it);
+ const selected = selectedName === it.name;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/src/lib/filebrowser.ts b/src/lib/filebrowser.ts
new file mode 100644
index 0000000..fdb4158
--- /dev/null
+++ b/src/lib/filebrowser.ts
@@ -0,0 +1,133 @@
+/**
+ * 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 {
+ 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 {
+ 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/
+ 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`;
+}