Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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