chore(documents): remove legacy /documents/files route + folder tree
The /documents/files page rendered a storagePath-prefix folder tree disconnected from document_folders. Replaced by the unified hub (Task 15). 301 redirect catches stray bookmarks. file-browser-store repurposed to hold the document_folders.id selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,20 @@ const nextConfig: NextConfig = {
|
|||||||
// process.cwd() requires the file to be traced explicitly.
|
// process.cwd() requires the file to be traced explicitly.
|
||||||
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
||||||
},
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:portSlug/documents/files',
|
||||||
|
destination: '/:portSlug/documents',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/:portSlug/documents/files/:path*',
|
||||||
|
destination: '/:portSlug/documents',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Grid, List, Upload } from 'lucide-react';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
|
||||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
|
||||||
import { FileGrid } from '@/components/files/file-grid';
|
|
||||||
import { FolderTree } from '@/components/files/folder-tree';
|
|
||||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
|
||||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
|
||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|
||||||
import { useFileBrowserStore } from '@/stores/file-browser-store';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
|
||||||
import type { FileRow } from '@/components/files/file-grid';
|
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore();
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
|
||||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
|
||||||
const [, setRenameFile] = useState<FileRow | null>(null);
|
|
||||||
|
|
||||||
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
|
|
||||||
queryKey: ['files'],
|
|
||||||
endpoint: '/api/v1/files',
|
|
||||||
filterDefinitions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
|
||||||
'file:uploaded': [['files']],
|
|
||||||
'file:updated': [['files']],
|
|
||||||
'file:deleted': [['files']],
|
|
||||||
});
|
|
||||||
|
|
||||||
const filesInFolder = currentFolder
|
|
||||||
? data.filter((f) => f.storagePath?.includes(currentFolder))
|
|
||||||
: data;
|
|
||||||
|
|
||||||
const handleDownload = async (file: FileRow) => {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
|
||||||
`/api/v1/files/${file.id}/download`,
|
|
||||||
);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = res.data.url;
|
|
||||||
a.download = res.data.filename;
|
|
||||||
a.click();
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (file: FileRow) => {
|
|
||||||
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col gap-4">
|
|
||||||
<PageHeader
|
|
||||||
title="Documents"
|
|
||||||
description="Store and manage port documents and attachments"
|
|
||||||
actions={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
|
||||||
>
|
|
||||||
{viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
<PermissionGate resource="files" action="upload">
|
|
||||||
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
|
|
||||||
<Upload className="mr-1.5 h-4 w-4" />
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
</PermissionGate>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showUpload && (
|
|
||||||
<PermissionGate resource="files" action="upload">
|
|
||||||
<FileUploadZone
|
|
||||||
onUploadComplete={() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
|
||||||
setShowUpload(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PermissionGate>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
|
||||||
{/* Folder tree sidebar */}
|
|
||||||
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
|
|
||||||
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
Folders
|
|
||||||
</p>
|
|
||||||
<FolderTree
|
|
||||||
files={data}
|
|
||||||
currentFolder={currentFolder}
|
|
||||||
onFolderSelect={setCurrentFolder}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
|
|
||||||
<FileGrid
|
|
||||||
files={filesInFolder}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
onPreview={setPreviewFile}
|
|
||||||
onRename={setRenameFile}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilePreviewDialog
|
|
||||||
open={!!previewFile}
|
|
||||||
onOpenChange={(open) => !open && setPreviewFile(null)}
|
|
||||||
fileId={previewFile?.id}
|
|
||||||
fileName={previewFile?.filename}
|
|
||||||
mimeType={previewFile?.mimeType ?? undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { FileRow } from '@/components/files/file-grid';
|
|
||||||
|
|
||||||
interface FolderNode {
|
|
||||||
name: string;
|
|
||||||
fullPath: string;
|
|
||||||
children: Record<string, FolderNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFolderTree(files: FileRow[]): FolderNode {
|
|
||||||
const root: FolderNode = { name: '', fullPath: '', children: {} };
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const parts = file.storagePath ? file.storagePath.split('/').slice(0, -1) : [];
|
|
||||||
if (parts.length <= 1) continue; // skip files directly in root/port folder
|
|
||||||
|
|
||||||
let node = root;
|
|
||||||
let accumulated = '';
|
|
||||||
for (const part of parts.slice(1)) { // skip portSlug prefix
|
|
||||||
accumulated = accumulated ? `${accumulated}/${part}` : part;
|
|
||||||
if (!node.children[part]) {
|
|
||||||
node.children[part] = { name: part, fullPath: accumulated, children: {} };
|
|
||||||
}
|
|
||||||
node = node.children[part]!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FolderNodeComponentProps {
|
|
||||||
node: FolderNode;
|
|
||||||
currentFolder: string;
|
|
||||||
onFolderSelect: (path: string) => void;
|
|
||||||
depth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderNodeComponent({
|
|
||||||
node,
|
|
||||||
currentFolder,
|
|
||||||
onFolderSelect,
|
|
||||||
depth = 0,
|
|
||||||
}: FolderNodeComponentProps) {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const hasChildren = Object.keys(node.children).length > 0;
|
|
||||||
const isSelected = currentFolder === node.fullPath;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onFolderSelect(node.fullPath);
|
|
||||||
if (hasChildren) setExpanded((v) => !v);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted/60 transition-colors',
|
|
||||||
isSelected && 'bg-muted font-medium',
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
expanded ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="w-3.5" />
|
|
||||||
)}
|
|
||||||
{isSelected ? (
|
|
||||||
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{node.name}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasChildren && expanded && (
|
|
||||||
<div>
|
|
||||||
{Object.values(node.children).map((child) => (
|
|
||||||
<FolderNodeComponent
|
|
||||||
key={child.fullPath}
|
|
||||||
node={child}
|
|
||||||
currentFolder={currentFolder}
|
|
||||||
onFolderSelect={onFolderSelect}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FolderTreeProps {
|
|
||||||
files: (FileRow & { storagePath: string })[];
|
|
||||||
currentFolder: string;
|
|
||||||
onFolderSelect: (path: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderTree({ files, currentFolder, onFolderSelect }: FolderTreeProps) {
|
|
||||||
const tree = buildFolderTree(files);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onFolderSelect('')}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted/60 transition-colors',
|
|
||||||
currentFolder === '' && 'bg-muted font-medium',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="w-3.5" />
|
|
||||||
{currentFolder === '' ? (
|
|
||||||
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span>All Files</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{Object.values(tree.children).map((child) => (
|
|
||||||
<FolderNodeComponent
|
|
||||||
key={child.fullPath}
|
|
||||||
node={child}
|
|
||||||
currentFolder={currentFolder}
|
|
||||||
onFolderSelect={onFolderSelect}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
|
||||||
Bell,
|
Bell,
|
||||||
Camera,
|
Camera,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -116,10 +115,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{
|
{
|
||||||
title: 'Documents',
|
title: 'Documents',
|
||||||
marinaRequired: true,
|
marinaRequired: true,
|
||||||
items: [
|
items: [{ href: `${base}/documents`, label: 'Documents', icon: FileText }],
|
||||||
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
|
|
||||||
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Financial',
|
title: 'Financial',
|
||||||
|
|||||||
@@ -1010,7 +1010,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
|||||||
icon: Folder,
|
icon: Folder,
|
||||||
label: f.filename,
|
label: f.filename,
|
||||||
sub: f.ownerLabel,
|
sub: f.ownerLabel,
|
||||||
href: `/${portSlug}/documents/files`,
|
href: `/${portSlug}/documents`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface FileBrowserStore {
|
interface FileBrowserState {
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
currentFolder: string;
|
|
||||||
selectedFiles: string[];
|
|
||||||
setViewMode: (mode: 'grid' | 'list') => void;
|
setViewMode: (mode: 'grid' | 'list') => void;
|
||||||
setCurrentFolder: (folder: string) => void;
|
selectedFolderId: string | null | undefined; // undefined = no selection, null = root
|
||||||
toggleFileSelection: (fileId: string) => void;
|
setSelectedFolderId: (id: string | null | undefined) => void;
|
||||||
clearSelection: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFileBrowserStore = create<FileBrowserStore>((set) => ({
|
export const useFileBrowserStore = create<FileBrowserState>((set) => ({
|
||||||
viewMode: 'grid',
|
viewMode: 'list',
|
||||||
currentFolder: '',
|
setViewMode: (viewMode) => set({ viewMode }),
|
||||||
selectedFiles: [],
|
selectedFolderId: undefined,
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
setSelectedFolderId: (selectedFolderId) => set({ selectedFolderId }),
|
||||||
setCurrentFolder: (folder) => set({ currentFolder: folder, selectedFiles: [] }),
|
|
||||||
toggleFileSelection: (fileId) =>
|
|
||||||
set((state) => ({
|
|
||||||
selectedFiles: state.selectedFiles.includes(fileId)
|
|
||||||
? state.selectedFiles.filter((id) => id !== fileId)
|
|
||||||
: [...state.selectedFiles, fileId],
|
|
||||||
})),
|
|
||||||
clearSelection: () => set({ selectedFiles: [] }),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user