source-gamepanel/apps/web/src/pages/server/files.tsx

280 lines
9.6 KiB
TypeScript

import { useState } from 'react';
import { useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Folder,
FileText,
ArrowUp,
Trash2,
Plus,
Download,
Upload,
Save,
X,
} from 'lucide-react';
import { api } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modifiedAt: number;
}
export function FilesPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState('/');
const [editingFile, setEditingFile] = useState<{ path: string; content: string } | null>(null);
const [newFileName, setNewFileName] = useState('');
const [showNewFile, setShowNewFile] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const filesQuery = useQuery({
queryKey: ['files', orgId, serverId, currentPath],
queryFn: () =>
api.get<{ files: FileEntry[] }>(
`/organizations/${orgId}/servers/${serverId}/files`,
{ path: currentPath },
),
enabled: !editingFile,
});
const deleteMutation = useMutation({
mutationFn: (paths: string[]) =>
api.post(`/organizations/${orgId}/servers/${serverId}/files/delete`, { paths }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
setDeleteTarget(null);
},
});
const saveMutation = useMutation({
mutationFn: ({ path, data }: { path: string; data: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
onSuccess: () => {
setEditingFile(null);
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
},
});
const createFileMutation = useMutation({
mutationFn: ({ path, data }: { path: string; data: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
onSuccess: () => {
setShowNewFile(false);
setNewFileName('');
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
},
});
const openFile = async (file: FileEntry) => {
if (file.isDirectory) {
setCurrentPath(file.path);
return;
}
const res = await api.get<{ data: string }>(
`/organizations/${orgId}/servers/${serverId}/files/read`,
{ path: file.path },
);
setEditingFile({ path: file.path, content: res.data });
};
const goUp = () => {
if (currentPath === '/') return;
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
setCurrentPath('/' + parts.join('/'));
};
const breadcrumbs = currentPath.split('/').filter(Boolean);
const files = filesQuery.data?.files ?? [];
return (
<div className="space-y-4">
{editingFile ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-sm font-mono">{editingFile.path}</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
onClick={() =>
saveMutation.mutate({ path: editingFile.path, data: editingFile.content })
}
disabled={saveMutation.isPending}
>
<Save className="h-4 w-4" />
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingFile(null)}>
<X className="h-4 w-4" />
Close
</Button>
</div>
</CardHeader>
<CardContent>
<textarea
value={editingFile.content}
onChange={(e) => setEditingFile({ ...editingFile, content: e.target.value })}
className="min-h-[500px] w-full rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
spellCheck={false}
/>
</CardContent>
</Card>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<Button variant="ghost" size="icon" onClick={goUp} disabled={currentPath === '/'}>
<ArrowUp className="h-4 w-4" />
</Button>
<span className="text-muted-foreground">/</span>
{breadcrumbs.map((crumb, i) => (
<span key={i} className="flex items-center gap-1">
<button
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setCurrentPath('/' + breadcrumbs.slice(0, i + 1).join('/'))
}
>
{crumb}
</button>
{i < breadcrumbs.length - 1 && (
<span className="text-muted-foreground">/</span>
)}
</span>
))}
</div>
<Button size="sm" variant="outline" onClick={() => setShowNewFile(true)}>
<Plus className="h-4 w-4" />
New File
</Button>
</div>
{showNewFile && (
<div className="flex gap-2">
<Input
placeholder="filename.txt"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newFileName) {
const path =
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
createFileMutation.mutate({ path, data: '' });
}
}}
/>
<Button
size="sm"
onClick={() => {
if (!newFileName) return;
const path =
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
createFileMutation.mutate({ path, data: '' });
}}
>
Create
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFile(false);
setNewFileName('');
}}
>
Cancel
</Button>
</div>
)}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{files.length === 0 && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
This directory is empty
</div>
)}
{files.map((file) => (
<div
key={file.path}
className="flex cursor-pointer items-center justify-between px-4 py-2.5 hover:bg-muted/50"
onClick={() => openFile(file)}
>
<div className="flex items-center gap-3">
{file.isDirectory ? (
<Folder className="h-4 w-4 text-blue-400" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm">{file.name}</span>
</div>
<div className="flex items-center gap-4">
{!file.isDirectory && (
<span className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</span>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(file.path);
}}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete <code className="font-mono">{deleteTarget}</code>?
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => deleteTarget && deleteMutation.mutate([deleteTarget])}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}