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:
2026-07-06 01:56:40 -04:00
parent 0da789cd96
commit 50ed8abe95
17 changed files with 2209 additions and 30 deletions
+2
View File
@@ -10,6 +10,7 @@ import Samba from "./pages/Samba";
import Nfs from "./pages/Nfs";
import Log from "./pages/Log";
import Settings from "./pages/Settings";
import Files from "./pages/Files";
type AuthState = { loading: boolean; authenticated: boolean; username: string };
@@ -52,6 +53,7 @@ export default function App() {
>
<Route path="/" element={<Dashboard />} />
<Route path="/users" element={<Users />} />
<Route path="/files" element={<Files />} />
<Route path="/samba" element={<Samba />} />
<Route path="/nfs" element={<Nfs />} />
<Route path="/log" element={<Log />} />
+80 -2
View File
@@ -75,6 +75,58 @@ export interface VersionInfo {
commit: string;
}
export interface FileEntry {
name: string;
path: string;
is_dir: boolean;
size: number;
mode: string;
mode_num: number;
mod_time: number;
}
export interface FileInfo {
path: string;
name: string;
is_dir: boolean;
size: number;
mode: string;
mode_num: number;
mod_time: number;
uid: number;
gid: number;
total_bytes?: number;
free_bytes?: number;
}
export interface DirList {
entries: FileEntry[];
path: string;
total: number;
page: number;
limit: number;
has_more: boolean;
}
export interface SearchHit {
name: string;
path: string;
is_dir: boolean;
size: number;
}
export interface FileCapabilities {
chmod: boolean;
chown: boolean;
}
export interface FilePreview {
type: "text" | "image" | "binary";
content?: string;
mime: string;
size: number;
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
@@ -84,10 +136,11 @@ export class ApiError extends Error {
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const isFormData = body instanceof FormData;
const res = await fetch(`/api${path}`, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
headers: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined,
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) {
return undefined as T;
@@ -138,6 +191,31 @@ export const api = {
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
// files
fileCapabilities: () => request<FileCapabilities>("GET", "/files/capabilities"),
fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"),
listFiles: (path: string, page = 1, limit = 200) =>
request<DirList>("GET", `/files/?path=${encodeURIComponent(path)}&page=${page}&limit=${limit}`),
fileInfo: (path: string) => request<FileInfo>("GET", `/files/info?path=${encodeURIComponent(path)}`),
mkdirFile: (path: string) => request<void>("POST", "/files/mkdir", { path }),
renameFile: (path: string, newName: string) =>
request<void>("POST", "/files/rename", { path, newName }),
chmodFile: (path: string, mode: string) =>
request<void>("POST", "/files/chmod", { path, mode }),
chownFile: (path: string, uid: number, gid: number) =>
request<void>("POST", "/files/chown", { path, uid, gid }),
deleteFile: (path: string) => request<void>("DELETE", `/files/?path=${encodeURIComponent(path)}`),
uploadFile: (dir: string, file: File) => {
const fd = new FormData();
fd.append("file", file);
return request<{ path: string }>("POST", `/files/upload?path=${encodeURIComponent(dir)}`, fd);
},
filePreview: (path: string) =>
request<FilePreview>("GET", `/files/preview?path=${encodeURIComponent(path)}`),
searchFiles: (path: string, q: string, limit = 100) =>
request<{ results: SearchHit[] }>(`GET`, `/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(q)}&limit=${limit}`),
fileDownloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`,
};
export function formatBytes(bytes: number): string {
+328
View File
@@ -0,0 +1,328 @@
import { useEffect, useState, useRef, DragEvent } from "react";
import Modal from "./Modal";
import { api, FileEntry, FileCapabilities, formatBytes } from "../api";
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>;
if (name.includes("/")) return <span className="text-yellow-400">📁</span>;
return <span className="text-slate-400">📄</span>;
}
interface Props {
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
}
export default function FileBrowserModal({ initialPath, onSelect, onClose }: Props) {
const [roots, setRoots] = useState<string[]>([]);
const [currentPath, setCurrentPath] = useState(initialPath ?? "/");
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 [showMkdir, setShowMkdir] = useState(false);
const [mkdirName, setMkdirName] = useState("");
const [renameState, setRenameState] = useState<{ path: string; name: string } | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const LIMIT = 200;
useEffect(() => {
api.fileCapabilities().then(setCapabilities).catch(() => {});
api.fileRoots().then(r => {
setRoots(r.roots);
if (!initialPath && r.roots.length > 0) {
setCurrentPath(r.roots[0]);
}
}).catch(() => {});
}, []);
useEffect(() => {
loadDir(currentPath, 1);
}, [currentPath]);
async function loadDir(path: string, pageNum: number) {
setLoading(true);
setError(null);
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);
}
async function navigateTo(path: string) {
setCurrentPath(path);
setSelectedPath(null);
}
function handleDoubleClick(entry: FileEntry) {
if (entry.is_dir) {
navigateTo(entry.path);
} else {
setSelectedPath(entry.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.path, renameState.name);
setRenameState(null);
refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Error renaming");
}
}
async function handleDelete(entry: FileEntry) {
if (!confirm(`¿Eliminar "${entry.name}"${entry.is_dir ? " y su contenido" : ""}?`)) return;
try {
await api.deleteFile(entry.path);
refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Error deleting");
}
}
async function handleUpload(fileList: FileList | null) {
if (!fileList) return;
for (const file of Array.from(fileList)) {
try {
await api.uploadFile(currentPath, file);
} catch (e) {
setError(e instanceof Error ? e.message : "Error uploading");
}
}
refresh();
}
function handleDropZone(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
e.stopPropagation();
handleUpload(e.dataTransfer.files);
}
const parentDir = currentPath === "/" ? null : (() => {
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
return "/" + parts.join("/");
})();
const breadcrumbs = currentPath.split("/").filter(Boolean).map((part, i, arr) => {
const path = "/" + arr.slice(0, i + 1).join("/");
return { part, path };
});
return (
<Modal title="Explorar directorio" onClose={onClose} maxWidth="4xl">
<div className="space-y-3">
{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-2 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>
)}
<div className="flex items-center gap-1 text-sm text-slate-300">
<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" onClick={() => navigateTo(b.path)}>{b.part}</button>
{i < breadcrumbs.length - 1 && <span className="mx-1 text-slate-600">/</span>}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<button className="btn-ghost text-xs" onClick={refresh}></button>
<button className="btn-ghost text-xs" onClick={() => { setShowMkdir(true); setMkdirName(""); }}>📁+ Nueva</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()}> Subir</button>
<button
className="btn-primary text-xs ml-auto"
disabled={!selectedPath}
onClick={() => selectedPath && onSelect(selectedPath)}
>
Seleccionar
</button>
</div>
<div
className="max-h-80 overflow-y-auto rounded border border-slate-700"
onDragOver={e => e.preventDefault()}
onDrop={handleDropZone}
>
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-slate-900 border-b border-slate-700">
<tr>
<th className="px-3 py-2 text-slate-400">Nombre</th>
<th className="px-3 py-2 text-slate-400">Tamaño</th>
<th className="px-3 py-2 text-slate-400">Modificado</th>
<th className="px-3 py-2 text-slate-400"></th>
</tr>
</thead>
<tbody>
{parentDir && (
<tr
className="cursor-pointer hover:bg-slate-800"
onDoubleClick={() => navigateTo(parentDir!)}
>
<td className="px-3 py-2 text-slate-400">..</td>
<td className="px-3 py-2 text-slate-500"></td>
<td className="px-3 py-2 text-slate-500"></td>
<td className="px-3 py-2"></td>
</tr>
)}
{entries.map(entry => (
<tr
key={entry.path}
className={`cursor-pointer hover:bg-slate-800 ${selectedPath === entry.path ? "bg-slate-700" : ""}`}
onClick={() => setSelectedPath(entry.path)}
onDoubleClick={() => handleDoubleClick(entry)}
>
<td className="px-3 py-2 flex items-center gap-2">
<FileIcon name={entry.name} />
<span className="text-slate-100">{entry.name}</span>
</td>
<td className="px-3 py-2 text-slate-400 font-mono text-xs">
{entry.is_dir ? "—" : formatBytes(entry.size)}
</td>
<td className="px-3 py-2 text-slate-400 text-xs">
{entry.mod_time > 0 ? new Date(entry.mod_time * 1000).toLocaleDateString() : "—"}
</td>
<td className="px-3 py-2 text-right">
<div className="flex gap-1 justify-end">
{capabilities?.chmod && (
<button
className="btn-ghost text-xs px-1 py-0.5"
onClick={e => { e.stopPropagation(); setRenameState({ path: entry.path, name: entry.name }); }}
>
</button>
)}
<button
className="btn-ghost text-xs px-1 py-0.5 text-red-400"
onClick={e => { e.stopPropagation(); handleDelete(entry); }}
>
</button>
</div>
</td>
</tr>
))}
{entries.length === 0 && !loading && (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
Directorio vacío
</td>
</tr>
)}
{loading && (
<tr>
<td colSpan={4} className="px-3 py-4 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 && (
<div className="flex gap-2 items-center">
<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 text-xs" onClick={handleMkdir}>Crear</button>
<button className="btn-ghost text-xs" onClick={() => setShowMkdir(false)}>Cancelar</button>
</div>
)}
{renameState && (
<div className="flex gap-2 items-center border-t border-slate-700 pt-3">
<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 text-xs" onClick={handleRename}>Renombrar</button>
<button className="btn-ghost text-xs" onClick={() => setRenameState(null)}>Cancelar</button>
</div>
)}
</div>
</Modal>
);
}
+1
View File
@@ -6,6 +6,7 @@ import DirtyBanner from "./DirtyBanner";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/users", label: "Usuarios" },
{ to: "/files", label: "Archivos" },
{ to: "/samba", label: "SMB / Samba" },
{ to: "/nfs", label: "NFS" },
{ to: "/log", label: "Historial" },
+14 -1
View File
@@ -1,18 +1,31 @@
import { ReactNode } from "react";
const widthMap: Record<string, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
"4xl": "max-w-4xl",
full: "max-w-full",
};
export default function Modal({
title,
onClose,
children,
maxWidth = "lg",
}: {
title: string;
onClose: () => void;
children: ReactNode;
maxWidth?: keyof typeof widthMap;
}) {
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="w-full max-w-lg rounded-lg border border-slate-700 bg-slate-900 p-6 shadow-xl"
className={`w-full ${widthMap[maxWidth]} rounded-lg border border-slate-700 bg-slate-900 p-6 shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
import FileBrowserModal from "./FileBrowserModal";
interface Props {
value: string;
onChange: (path: string) => void;
}
export default function PathField({ value, onChange }: Props) {
const [open, setOpen] = useState(false);
return (
<div>
<div className="flex gap-2">
<input
className="input flex-1"
value={value}
onChange={e => onChange(e.target.value)}
placeholder="/ruta/absoluta"
/>
<button
type="button"
className="btn-ghost whitespace-nowrap"
onClick={() => setOpen(true)}
>
Explorar
</button>
</div>
{open && (
<FileBrowserModal
initialPath={value || "/"}
onSelect={p => { onChange(p); setOpen(false); }}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}
+649
View File
@@ -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>
);
}
+3 -3
View File
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
import { api, NFSExport } from "../api";
import { useDirty } from "../DirtyContext";
import Modal from "../components/Modal";
import PathField from "../components/PathField";
const empty: Partial<NFSExport> = {
path: "",
@@ -160,10 +161,9 @@ export default function Nfs() {
)}
<div>
<label className="label">Path (absoluto)</label>
<input
className="input"
<PathField
value={editing.path ?? ""}
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
onChange={p => setEditing({ ...editing, path: p })}
/>
</div>
<div>
+3 -3
View File
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
import { api, SambaShare } from "../api";
import { useDirty } from "../DirtyContext";
import Modal from "../components/Modal";
import PathField from "../components/PathField";
const empty: Partial<SambaShare> = {
name: "",
@@ -119,10 +120,9 @@ export default function Samba() {
</div>
<div>
<label className="label">Path (absoluto)</label>
<input
className="input"
<PathField
value={editing.path ?? ""}
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
onChange={p => setEditing({ ...editing, path: p })}
/>
</div>
<div>