feat: file manager with browse, search, upload, chmod/chown
- Full file browser page at /files with lazy-load, breadcrumbs, drag&drop - FileBrowserModal component for path selection from Samba/NFS forms - PathField component replaces bare inputs in share/export forms - Backend: /api/files/* routes with List, Mkdir, Rename, Delete, Chmod, Chown, Upload, Download, Preview, Search - Reuses NASCTL_ALLOWED_ROOTS for path validation - NASCTL_UPLOAD_MAX_BYTES (100MB) and NASCTL_PREVIEW_MAX_BYTES (256KB) env vars - Capabilities endpoint returns chmod/chown availability (requires root) - Version bump: 0.3.2 -> 0.4.0
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
import { useEffect, useState, useRef, DragEvent, useCallback } from "react";
|
||||
import { api, FileEntry, FileCapabilities, FilePreview, SearchHit, formatBytes } from "../api";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
function FileIcon({ name }: { name: string }) {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (name === "..") return <span className="text-slate-500">↑</span>;
|
||||
if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "gif" || ext === "webp" || ext === "svg")
|
||||
return <span className="text-blue-400">🖼</span>;
|
||||
if (ext === "mp4" || ext === "avi" || ext === "mkv" || ext === "mov")
|
||||
return <span className="text-purple-400">🎬</span>;
|
||||
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac")
|
||||
return <span className="text-green-400">🎵</span>;
|
||||
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z")
|
||||
return <span className="text-yellow-400">📦</span>;
|
||||
if (ext === "pdf")
|
||||
return <span className="text-red-400">📄</span>;
|
||||
if (ext === "txt" || ext === "md" || ext === "log" || ext === "cfg" || ext === "conf" || ext === "json" || ext === "yaml" || ext === "yml" || ext === "toml" || ext === "xml")
|
||||
return <span className="text-emerald-400">📝</span>;
|
||||
return <span className="text-slate-400">📄</span>;
|
||||
}
|
||||
|
||||
function modeStr(mode: number): string {
|
||||
const perm = (mode & 0o777).toString(8).padStart(3, "0");
|
||||
const type = mode & 0o170000;
|
||||
if (type === 0o40000) return `drwxr-xr-x`.slice(0, 10 - perm.length) + perm;
|
||||
if (type === 0o120000) return `lrwxr-xr-x`.slice(0, 10 - perm.length) + perm;
|
||||
return `-rwxr-xr-x`.slice(0, 10 - perm.length) + perm;
|
||||
}
|
||||
|
||||
export default function Files() {
|
||||
const [roots, setRoots] = useState<string[]>([]);
|
||||
const [unrestricted, setUnrestricted] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [capabilities, setCapabilities] = useState<FileCapabilities | null>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const [showMkdir, setShowMkdir] = useState(false);
|
||||
const [mkdirName, setMkdirName] = useState("");
|
||||
|
||||
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
|
||||
const [preview, setPreview] = useState<FilePreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const [renameState, setRenameState] = useState<{ entry: FileEntry; name: string } | null>(null);
|
||||
const [chmodState, setChmodState] = useState<{ entry: FileEntry; mode: string } | null>(null);
|
||||
const [chownState, setChownState] = useState<{ entry: FileEntry; uid: string; gid: string } | null>(null);
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const LIMIT = 200;
|
||||
|
||||
useEffect(() => {
|
||||
api.fileCapabilities().then(setCapabilities).catch(() => {});
|
||||
api.fileRoots().then(r => {
|
||||
setRoots(r.roots);
|
||||
setUnrestricted(r.unrestricted);
|
||||
if (r.roots.length > 0) {
|
||||
setCurrentPath(r.roots[0]);
|
||||
} else if (!r.unrestricted) {
|
||||
setCurrentPath("/");
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPath) {
|
||||
loadDir(currentPath, 1);
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
async function loadDir(path: string, pageNum: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSearchResults([]);
|
||||
setSearchQuery("");
|
||||
try {
|
||||
const res = await api.listFiles(path, pageNum, LIMIT);
|
||||
if (pageNum === 1) {
|
||||
setEntries(res.entries);
|
||||
} else {
|
||||
setEntries(prev => [...prev, ...res.entries]);
|
||||
}
|
||||
setPage(pageNum);
|
||||
setHasMore(res.has_more);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error loading directory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
loadDir(currentPath, 1);
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
setSelectedEntry(null);
|
||||
setPreview(null);
|
||||
setShowPreview(false);
|
||||
setCurrentPath(path);
|
||||
}
|
||||
|
||||
function handleDoubleClick(entry: FileEntry) {
|
||||
if (entry.is_dir) {
|
||||
navigateTo(entry.path);
|
||||
} else {
|
||||
openPreview(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPreview(entry: FileEntry) {
|
||||
setSelectedEntry(entry);
|
||||
setPreviewLoading(true);
|
||||
setShowPreview(true);
|
||||
try {
|
||||
const p = await api.filePreview(entry.path);
|
||||
setPreview(p);
|
||||
} catch (e) {
|
||||
setPreview({ type: "binary", mime: "", size: entry.size });
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function parentDir(): string | null {
|
||||
if (currentPath === "/" || currentPath === "") return null;
|
||||
const parts = currentPath.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
const parent = "/" + parts.join("/");
|
||||
return parent === "/" ? "/" : parent;
|
||||
}
|
||||
|
||||
const breadcrumbs = currentPath.split("/").filter(Boolean).map((part, i, arr) => {
|
||||
const path = "/" + arr.slice(0, i + 1).join("/");
|
||||
return { part, path };
|
||||
});
|
||||
|
||||
async function handleMkdir() {
|
||||
if (!mkdirName.trim()) return;
|
||||
try {
|
||||
await api.mkdirFile(currentPath + "/" + mkdirName.trim());
|
||||
setMkdirName("");
|
||||
setShowMkdir(false);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error creating folder");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!renameState) return;
|
||||
try {
|
||||
await api.renameFile(renameState.entry.path, renameState.name);
|
||||
setRenameState(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error renaming");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChmod() {
|
||||
if (!chmodState) return;
|
||||
try {
|
||||
await api.chmodFile(chmodState.entry.path, chmodState.mode);
|
||||
setChmodState(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error changing permissions");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChown() {
|
||||
if (!chownState) return;
|
||||
const uid = parseInt(chownState.uid, 10);
|
||||
const gid = parseInt(chownState.gid, 10);
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
setError("UID y GID deben ser numéricos");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.chownFile(chownState.entry.path, uid, gid);
|
||||
setChownState(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error changing owner");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entry: FileEntry) {
|
||||
if (!confirm(`¿Eliminar "${entry.name}"${entry.is_dir ? " y todo su contenido" : ""}?`)) return;
|
||||
try {
|
||||
await api.deleteFile(entry.path);
|
||||
if (selectedEntry?.path === entry.path) {
|
||||
setSelectedEntry(null);
|
||||
setPreview(null);
|
||||
setShowPreview(false);
|
||||
}
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error deleting");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(fileList: FileList | null) {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
setUploading(true);
|
||||
for (const file of Array.from(fileList)) {
|
||||
try {
|
||||
await api.uploadFile(currentPath, file);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : `Error uploading ${file.name}`);
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function doSearch(q: string) {
|
||||
if (!q.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await api.searchFiles(currentPath, q, 100);
|
||||
setSearchResults(res.results);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error searching");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
doSearch(searchQuery);
|
||||
}, [searchQuery, currentPath]);
|
||||
|
||||
function handleDropZone(e: DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleUpload(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
|
||||
function selectSearchResult(hit: SearchHit) {
|
||||
const dir = hit.path.substring(0, hit.path.lastIndexOf("/")) || "/";
|
||||
navigateTo(dir);
|
||||
setSelectedEntry({ name: hit.name, path: hit.path, is_dir: hit.is_dir, size: hit.size, mode: "", mode_num: 0, mod_time: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Archivos</h1>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button className="btn-ghost" onClick={refresh}>↻ Actualizar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{roots.length > 1 && (
|
||||
<select
|
||||
className="input w-auto"
|
||||
value={roots.includes(currentPath) ? currentPath : roots[0]}
|
||||
onChange={e => navigateTo(e.target.value)}
|
||||
>
|
||||
{roots.map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{unrestricted && (
|
||||
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-1 rounded border border-amber-700/50">
|
||||
Sin restricción — acceso a todo el FS
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm text-slate-300 flex-1 min-w-0">
|
||||
<button
|
||||
className="btn-ghost px-2 py-1 text-xs"
|
||||
onClick={() => parentDir() && navigateTo(parentDir()!)}
|
||||
disabled={!parentDir()}
|
||||
>←</button>
|
||||
{breadcrumbs.map((b, i) => (
|
||||
<span key={i} className="flex items-center">
|
||||
<button className="hover:text-white truncate max-w-32" onClick={() => navigateTo(b.path)}>{b.part}</button>
|
||||
{i < breadcrumbs.length - 1 && <span className="mx-1 text-slate-600">/</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Buscar archivos y carpetas…"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={searching}>
|
||||
{searching ? "Buscando…" : "🔍"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="text-sm text-slate-400 mb-2">Resultados ({searchResults.length})</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((hit, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-800 cursor-pointer rounded"
|
||||
onClick={() => selectSearchResult(hit)}
|
||||
onDoubleClick={() => {
|
||||
if (hit.is_dir) {
|
||||
navigateTo(hit.path);
|
||||
} else {
|
||||
setSelectedEntry({ name: hit.name, path: hit.path, is_dir: hit.is_dir, size: hit.size, mode: "", mode_num: 0, mod_time: 0 });
|
||||
openPreview({ name: hit.name, path: hit.path, is_dir: hit.is_dir, size: hit.size, mode: "", mode_num: 0, mod_time: 0 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileIcon name={hit.name} />
|
||||
<span className="text-slate-100 text-sm truncate">{hit.path}</span>
|
||||
<span className="text-slate-500 text-xs ml-auto">{hit.is_dir ? "carpeta" : formatBytes(hit.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-ghost text-xs mt-2 w-full" onClick={() => { setSearchResults([]); setSearchQuery(""); }}>
|
||||
Limpiar búsqueda
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost text-xs" onClick={() => { setShowMkdir(true); setMkdirName(""); }}>📁 Nueva carpeta</button>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
|
||||
<button className="btn-ghost text-xs" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? "↑ Subiendo…" : "⬆ Subir archivo"}
|
||||
</button>
|
||||
{capabilities?.chown && (
|
||||
<button
|
||||
className="btn-ghost text-xs ml-auto"
|
||||
onClick={() => {
|
||||
if (!selectedEntry) return;
|
||||
setChownState({ entry: selectedEntry, uid: String(selectedEntry.mode_num >> 16), gid: String(selectedEntry.mode_num & 0xFFFF) });
|
||||
}}
|
||||
disabled={!selectedEntry}
|
||||
>
|
||||
👤 Propietario
|
||||
</button>
|
||||
)}
|
||||
{capabilities?.chmod && (
|
||||
<button
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => {
|
||||
if (!selectedEntry) return;
|
||||
setChmodState({ entry: selectedEntry, mode: String((selectedEntry.mode_num & 0o777).toString(8)) });
|
||||
}}
|
||||
disabled={!selectedEntry}
|
||||
>
|
||||
🔒 Permisos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`card overflow-x-auto p-0 relative ${isDragOver ? "ring-2 ring-brand-500" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDropZone}
|
||||
>
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 bg-brand-500/20 flex items-center justify-center z-10 rounded-lg">
|
||||
<span className="text-brand-300 font-medium">Suelta para subir</span>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Nombre</th>
|
||||
<th className="px-4 py-3">Tamaño</th>
|
||||
<th className="px-4 py-3">Permisos</th>
|
||||
<th className="px-4 py-3">Modificado</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parentDir() !== null && (
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-slate-800"
|
||||
onDoubleClick={() => navigateTo(parentDir()!)}
|
||||
>
|
||||
<td className="px-4 py-2 text-slate-400">..</td>
|
||||
<td className="px-4 py-2 text-slate-500">—</td>
|
||||
<td className="px-4 py-2 text-slate-500">—</td>
|
||||
<td className="px-4 py-2 text-slate-500">—</td>
|
||||
<td className="px-4 py-2"></td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map(entry => (
|
||||
<tr
|
||||
key={entry.path}
|
||||
className={`cursor-pointer hover:bg-slate-800 ${selectedEntry?.path === entry.path ? "bg-slate-700" : ""}`}
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
onDoubleClick={() => handleDoubleClick(entry)}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon name={entry.name} />
|
||||
<span className="text-slate-100">{entry.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-400 font-mono text-xs">
|
||||
{entry.is_dir ? "—" : formatBytes(entry.size)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-400 font-mono text-xs">
|
||||
{modeStr(entry.mode_num)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-400 text-xs">
|
||||
{entry.mod_time > 0 ? new Date(entry.mod_time * 1000).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
className="btn-ghost text-xs px-1 py-0.5"
|
||||
onClick={e => { e.stopPropagation(); setRenameState({ entry, name: entry.name }); }}
|
||||
title="Renombrar"
|
||||
>✎</button>
|
||||
{capabilities?.chmod && (
|
||||
<button
|
||||
className="btn-ghost text-xs px-1 py-0.5"
|
||||
onClick={e => { e.stopPropagation(); setChmodState({ entry, mode: String((entry.mode_num & 0o777).toString(8)) }); }}
|
||||
title="Permisos"
|
||||
>🔒</button>
|
||||
)}
|
||||
{capabilities?.chown && (
|
||||
<button
|
||||
className="btn-ghost text-xs px-1 py-0.5"
|
||||
onClick={e => { e.stopPropagation(); setChownState({ entry, uid: String(entry.mode_num >> 16), gid: String(entry.mode_num & 0xFFFF) }); }}
|
||||
title="Propietario"
|
||||
>👤</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-ghost text-xs px-1 py-0.5 text-red-400"
|
||||
onClick={e => { e.stopPropagation(); handleDelete(entry); }}
|
||||
title="Eliminar"
|
||||
>✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
||||
{searchQuery ? "Sin resultados" : "Directorio vacío"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
||||
Cargando…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button className="btn-ghost w-full text-xs" onClick={() => loadDir(currentPath, page + 1)}>
|
||||
Cargar más ({total - entries.length} restantes)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showMkdir && (
|
||||
<Modal title="Nueva carpeta" onClose={() => setShowMkdir(false)} maxWidth="sm">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Nombre de carpeta"
|
||||
value={mkdirName}
|
||||
onChange={e => setMkdirName(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleMkdir()}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-primary" onClick={handleMkdir}>Crear</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{renameState && (
|
||||
<Modal title="Renombrar" onClose={() => setRenameState(null)} maxWidth="sm">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={renameState.name}
|
||||
onChange={e => setRenameState({ ...renameState, name: e.target.value })}
|
||||
onKeyDown={e => e.key === "Enter" && handleRename()}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-primary" onClick={handleRename}>Renombrar</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{chmodState && (
|
||||
<Modal title="Permisos" onClose={() => setChmodState(null)} maxWidth="sm">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
className="input w-24"
|
||||
value={chmodState.mode}
|
||||
onChange={e => setChmodState({ ...chmodState, mode: e.target.value })}
|
||||
onKeyDown={e => e.key === "Enter" && handleChmod()}
|
||||
autoFocus
|
||||
placeholder="755"
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">octal (ej: 755, 644)</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 font-mono">
|
||||
{(parseInt(chmodState.mode, 8) || 0).toString(8).padStart(3, "0")} = {[
|
||||
["r", (parseInt(chmodState.mode, 8) || 0) & 0o400],
|
||||
["w", (parseInt(chmodState.mode, 8) || 0) & 0o200],
|
||||
["x", (parseInt(chmodState.mode, 8) || 0) & 0o100],
|
||||
].map(([c, v]) => c + (v ? "✓" : "✗")).join(" ")}
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn-ghost" onClick={() => setChmodState(null)}>Cancelar</button>
|
||||
<button className="btn-primary" onClick={handleChmod}>Aplicar</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{chownState && (
|
||||
<Modal title="Cambiar propietario" onClose={() => setChownState(null)} maxWidth="sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">UID</label>
|
||||
<input
|
||||
className="input"
|
||||
value={chownState.uid}
|
||||
onChange={e => setChownState({ ...chownState, uid: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">GID</label>
|
||||
<input
|
||||
className="input"
|
||||
value={chownState.gid}
|
||||
onChange={e => setChownState({ ...chownState, gid: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn-ghost" onClick={() => setChownState(null)}>Cancelar</button>
|
||||
<button className="btn-primary" onClick={handleChown}>Aplicar</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showPreview && selectedEntry && (
|
||||
<Modal
|
||||
title={selectedEntry.name}
|
||||
onClose={() => { setShowPreview(false); setPreview(null); }}
|
||||
maxWidth="3xl"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{previewLoading ? (
|
||||
<div className="text-center text-slate-400 py-8">Cargando…</div>
|
||||
) : preview?.type === "text" ? (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-2">{preview.mime} · {formatBytes(preview.size)}</div>
|
||||
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 overflow-auto max-h-96 font-mono whitespace-pre-wrap break-all">
|
||||
{preview.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : preview?.type === "image" ? (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-2">{preview.mime} · {formatBytes(preview.size)}</div>
|
||||
<img
|
||||
src={api.fileDownloadUrl(selectedEntry.path)}
|
||||
alt={selectedEntry.name}
|
||||
className="max-h-96 mx-auto rounded border border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-slate-400 mb-2">Archivo binario</div>
|
||||
<div className="text-xs text-slate-500 mb-4">{preview?.mime} · {formatBytes(preview?.size ?? 0)}</div>
|
||||
<a
|
||||
href={api.fileDownloadUrl(selectedEntry.path)}
|
||||
download={selectedEntry.name}
|
||||
className="btn-primary"
|
||||
>
|
||||
⬇ Descargar
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!previewLoading && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<a
|
||||
href={api.fileDownloadUrl(selectedEntry.path)}
|
||||
download={selectedEntry.name}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
⬇ Descargar
|
||||
</a>
|
||||
<button className="btn-primary text-xs" onClick={() => { setShowPreview(false); setPreview(null); }}>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user