Import source: src/components/features/file-manager
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,411 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DndContext } from "@dnd-kit/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
CheckSquare,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit3,
|
||||||
|
Eye,
|
||||||
|
File,
|
||||||
|
FilePlus2,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
|
Grid2X2,
|
||||||
|
Home,
|
||||||
|
Info,
|
||||||
|
List,
|
||||||
|
MoreVertical,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Scissors,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { FileEntry } from "@/api/contracts/models";
|
||||||
|
import { panelService } from "@/api/services/panel-service";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CodeEditor } from "@/components/common/code-editor";
|
||||||
|
import { formatBytes } from "@/lib/utils";
|
||||||
|
|
||||||
|
const toolbar: Array<{ label: string; icon: LucideIcon; primary?: boolean }> = [
|
||||||
|
{ label: "File", icon: FilePlus2 },
|
||||||
|
{ label: "Folder", icon: FolderPlus },
|
||||||
|
{ label: "Copy", icon: Copy },
|
||||||
|
{ label: "Move", icon: Scissors },
|
||||||
|
{ label: "Upload", icon: Upload, primary: true },
|
||||||
|
{ label: "Download", icon: Download },
|
||||||
|
{ label: "Delete", icon: Trash2 },
|
||||||
|
{ label: "Restore", icon: RotateCcw },
|
||||||
|
{ label: "Rename", icon: Edit3 },
|
||||||
|
{ label: "Edit", icon: Edit3 },
|
||||||
|
{ label: "HTML Editor", icon: File },
|
||||||
|
{ label: "Permissions", icon: ShieldCheck },
|
||||||
|
{ label: "Extract", icon: Archive },
|
||||||
|
{ label: "Compress", icon: Archive },
|
||||||
|
];
|
||||||
|
|
||||||
|
const directories = [
|
||||||
|
"/home/coaxys",
|
||||||
|
"public_html",
|
||||||
|
"assets",
|
||||||
|
"cgi-bin",
|
||||||
|
"logs",
|
||||||
|
"mail",
|
||||||
|
"ssl",
|
||||||
|
"tmp",
|
||||||
|
"backups",
|
||||||
|
".ssh",
|
||||||
|
".trash",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FileManager() {
|
||||||
|
const [treeWidth, setTreeWidth] = useState(276);
|
||||||
|
const [detailsWidth, setDetailsWidth] = useState(330);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["files"],
|
||||||
|
queryFn: () => panelService.files.list(),
|
||||||
|
});
|
||||||
|
const files = data?.data ?? [];
|
||||||
|
|
||||||
|
function startTreeResize(event: ReactPointerEvent<HTMLDivElement>) {
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = treeWidth;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
function onMove(moveEvent: globalThis.PointerEvent) {
|
||||||
|
setTreeWidth(Math.min(460, Math.max(220, startWidth + moveEvent.clientX - startX)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDetailsResize(event: ReactPointerEvent<HTMLDivElement>) {
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = detailsWidth;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
function onMove(moveEvent: globalThis.PointerEvent) {
|
||||||
|
setDetailsWidth(Math.min(520, Math.max(270, startWidth - (moveEvent.clientX - startX))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext>
|
||||||
|
<div className="glass-panel overflow-hidden rounded-lg">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-gradient-to-r from-primary to-primary/75 px-3 py-2 text-primary-foreground">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Folder className="h-4 w-4" />
|
||||||
|
File Manager
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="hidden font-mono opacity-90 sm:inline">
|
||||||
|
/home/coaxys/public_html
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" size="sm" className="h-7 rounded-md px-2 text-xs">
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-border/60 bg-muted/40 px-2 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{toolbar.map(({ label, icon: Icon, primary }) => (
|
||||||
|
<Button
|
||||||
|
key={label}
|
||||||
|
variant={primary ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-md px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid min-h-[660px] lg:grid-cols-[var(--file-tree-width)_8px_minmax(0,1fr)]"
|
||||||
|
style={{ "--file-tree-width": `${treeWidth}px` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<aside className="hidden border-r border-border/60 bg-muted/30 lg:block">
|
||||||
|
<div className="flex items-center gap-1 border-b border-border/60 p-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-md"
|
||||||
|
aria-label="Home"
|
||||||
|
>
|
||||||
|
<Home className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-md"
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="h-7 w-full rounded-md border bg-card pl-7 pr-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="Search tree"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[604px] overflow-auto p-2 text-xs">
|
||||||
|
{directories.map((directory, index) => (
|
||||||
|
<button
|
||||||
|
key={directory}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left hover:bg-primary/10"
|
||||||
|
style={{ paddingLeft: `${8 + Math.min(index, 3) * 10}px` }}
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="truncate">{directory}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hidden cursor-col-resize bg-border/40 transition hover:bg-primary/35 lg:block"
|
||||||
|
onPointerDown={startTreeResize}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize directory tree pane"
|
||||||
|
title="Drag to resize"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="min-w-0">
|
||||||
|
<div className="flex flex-col gap-2 border-b p-2 xl:flex-row xl:items-center">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-md"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-md"
|
||||||
|
aria-label="Forward"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 rounded-md text-xs">
|
||||||
|
Up One Level
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">Path</span>
|
||||||
|
<input
|
||||||
|
className="h-8 min-w-0 flex-1 rounded-md border bg-card px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
defaultValue="/home/coaxys/public_html"
|
||||||
|
/>
|
||||||
|
<Button size="sm" className="h-8 rounded-md text-xs">
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-md"
|
||||||
|
aria-label="List view"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-md"
|
||||||
|
aria-label="Grid view"
|
||||||
|
>
|
||||||
|
<Grid2X2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid border-b border-border/60 bg-muted/30 px-2 py-2 text-xs md:grid-cols-[1fr_240px]">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<input type="checkbox" />
|
||||||
|
Show Hidden Files
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<input type="checkbox" />
|
||||||
|
Preserve permissions
|
||||||
|
</label>
|
||||||
|
<span className="text-muted-foreground">Selected: 0 files</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-2 md:mt-0">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
className="h-8 w-full rounded-md border bg-card pl-7 pr-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="Search files"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <div className="skeleton h-[360px]" /> : <FileTable files={files} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid gap-0 border-t bg-background p-3 xl:grid-cols-[minmax(0,1fr)_8px_var(--file-details-width)]"
|
||||||
|
style={{ "--file-details-width": `${detailsWidth}px` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<CodeEditor
|
||||||
|
title="Source preview"
|
||||||
|
language="php"
|
||||||
|
value={
|
||||||
|
"<?php\n// CoaxysPanel Monaco preview\nreturn ['status' => 'mock-only'];\n"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="hidden cursor-col-resize rounded-full bg-border/40 transition hover:bg-primary/35 xl:block"
|
||||||
|
onPointerDown={startDetailsResize}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize file details pane"
|
||||||
|
title="Drag to resize"
|
||||||
|
/>
|
||||||
|
<Card className="rounded-lg">
|
||||||
|
<CardContent className="space-y-3 p-3 text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Info className="h-4 w-4 text-primary" />
|
||||||
|
File details
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
["Name", "Home page script"],
|
||||||
|
["Type", "PHP source"],
|
||||||
|
["Permissions", "0644"],
|
||||||
|
["Owner", "coaxys"],
|
||||||
|
["Checksum", "sha256: 2b4f...91ce"],
|
||||||
|
["Storage", "37 GB of 100 GB used"],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="grid grid-cols-[92px_1fr] gap-2">
|
||||||
|
<span className="font-semibold text-muted-foreground">{label}</span>
|
||||||
|
<span className="truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" className="h-8 w-full rounded-md text-xs">
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
Permissions Editor
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileTable({ files }: { files: FileEntry[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[860px] border-collapse text-xs">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="w-8 border-b border-border/60 px-2 py-2 text-left">
|
||||||
|
<CheckSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</th>
|
||||||
|
{["Name", "Size", "Last Modified", "Type", "Permissions", "Owner", "Actions"].map(
|
||||||
|
(head) => (
|
||||||
|
<th
|
||||||
|
key={head}
|
||||||
|
className="whitespace-nowrap border-b border-border/60 px-2 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||||
|
>
|
||||||
|
{head}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{files.map((file) => {
|
||||||
|
const Icon = file.type === "directory" ? Folder : File;
|
||||||
|
return (
|
||||||
|
<tr key={file.id} className="hover:bg-primary/5">
|
||||||
|
<td className="border-b px-2 py-2">
|
||||||
|
<input type="checkbox" aria-label={`Select ${file.name}`} />
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-2 py-2">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<Icon
|
||||||
|
className={
|
||||||
|
file.type === "directory"
|
||||||
|
? "h-4 w-4 text-amber-500"
|
||||||
|
: "h-4 w-4 text-primary"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-2 py-2">
|
||||||
|
{file.type === "directory" ? "--" : formatBytes(file.size)}
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-2 py-2">
|
||||||
|
{new Date(file.modifiedAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-2 py-2">{file.mime}</td>
|
||||||
|
<td className="border-b px-2 py-2 font-mono">{file.permissions}</td>
|
||||||
|
<td className="border-b px-2 py-2">{file.owner}</td>
|
||||||
|
<td className="border-b px-2 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-md"
|
||||||
|
aria-label={`Preview ${file.name}`}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-md"
|
||||||
|
aria-label={`More actions ${file.name}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user