Frontend — consistent HSL token usage across remaining pages: - Users: shared Card, Badge with success/error tokens, h2 typography, animate-fadeIn - Audit: shared Card, muted-foreground text, animate-fadeIn - Clients: shared Card, Badge active/inactive, hsl(--primary) icon color - Storage: shared Card, StatusBadge for status pills, hsl warning/primary bars replacing hardcoded amber/blue, all gray text → muted-foreground - Login: hsl(--surface) bg, hsl(--primary) submit button, brand mark icon, animate-scaleIn card entry, hsl(--warning) dev notice Backend tests — convert print-only stubs to real assertions: - test_pptx_creator: mkdir, deterministic save path, assert file exists + slide count - test_gemini_schema_support: direct google.genai client, skipif guard on GOOGLE_API_KEY, JSON parse + Pydantic model validation assertions - test_openai_schema_support: clean skip (OpenAI removed in Phase 6) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { useSelector } from 'react-redux';
|
|
import { RootState } from '@/store/store';
|
|
import {
|
|
HardDrive,
|
|
FileText,
|
|
Download,
|
|
Trash2,
|
|
Loader2,
|
|
FolderOpen,
|
|
CheckSquare,
|
|
Square,
|
|
Layers,
|
|
AlertTriangle,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { Card } from '@/components/shared/Card';
|
|
import { StatusBadge } from '@/components/shared/StatusBadge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
|
|
import { toast } from 'sonner';
|
|
import { HamsterLoader } from '@/components/ui/hamster-loader';
|
|
|
|
interface StorageSummary {
|
|
total_presentations: number;
|
|
total_files: number;
|
|
total_size_bytes: number;
|
|
total_deleted: number;
|
|
total_master_decks: number;
|
|
master_deck_files: number;
|
|
master_deck_size_bytes: number;
|
|
}
|
|
|
|
interface StoragePresentation {
|
|
id: string;
|
|
title: string | null;
|
|
status: string;
|
|
created_at: string | null;
|
|
file_count: number;
|
|
total_size_bytes: number;
|
|
has_export: boolean;
|
|
}
|
|
|
|
interface ClientOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
}
|
|
|
|
|
|
export default function StoragePage() {
|
|
const user = useSelector((state: RootState) => state.auth.user);
|
|
const isSuperAdmin = user?.role === 'super_admin';
|
|
const defaultClientId = user?.clientId || undefined;
|
|
|
|
const [selectedClientId, setSelectedClientId] = useState<string | undefined>(
|
|
isSuperAdmin ? undefined : defaultClientId
|
|
);
|
|
const [clients, setClients] = useState<ClientOption[]>([]);
|
|
const [summary, setSummary] = useState<StorageSummary | null>(null);
|
|
const [presentations, setPresentations] = useState<StoragePresentation[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [deleteTarget, setDeleteTarget] = useState<StoragePresentation | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
|
const [purging, setPurging] = useState(false);
|
|
|
|
// Load client list for super_admin
|
|
useEffect(() => {
|
|
if (isSuperAdmin) {
|
|
fetch('/api/v1/admin/clients', { headers: getHeader() })
|
|
.then((r) => (r.ok ? r.json() : []))
|
|
.then((data) => setClients(data))
|
|
.catch(() => {});
|
|
}
|
|
}, [isSuperAdmin]);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setSelectedIds(new Set());
|
|
try {
|
|
const params = selectedClientId ? `?client_id=${selectedClientId}` : '';
|
|
const headers = getHeader();
|
|
const [summaryRes, presRes] = await Promise.all([
|
|
fetch(`/api/v1/admin/storage/summary${params}`, { headers }),
|
|
fetch(`/api/v1/admin/storage/presentations${params}`, { headers }),
|
|
]);
|
|
if (summaryRes.ok) setSummary(await summaryRes.json());
|
|
if (presRes.ok) setPresentations(await presRes.json());
|
|
} catch (e) {
|
|
console.error('Storage load error:', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selectedClientId]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const handleDownload = (id: string) => {
|
|
window.open(`/api/v1/admin/storage/presentations/${id}/download`, '_blank');
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/storage/presentations/${deleteTarget.id}`, {
|
|
method: 'DELETE',
|
|
headers: getHeader(),
|
|
});
|
|
if (res.ok) {
|
|
toast.success('Presentation deleted');
|
|
setDeleteTarget(null);
|
|
load();
|
|
} else {
|
|
toast.error('Failed to delete');
|
|
}
|
|
} catch {
|
|
toast.error('Failed to delete');
|
|
}
|
|
};
|
|
|
|
const handleBulkDelete = async () => {
|
|
try {
|
|
const res = await fetch('/api/v1/admin/storage/presentations/bulk-delete', {
|
|
method: 'POST',
|
|
headers: { ...getHeader(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
toast.success(`Deleted ${data.deleted_count} presentations`);
|
|
setBulkDeleteOpen(false);
|
|
load();
|
|
} else {
|
|
toast.error('Bulk delete failed');
|
|
}
|
|
} catch {
|
|
toast.error('Bulk delete failed');
|
|
}
|
|
};
|
|
|
|
const handlePurge = async () => {
|
|
setPurging(true);
|
|
try {
|
|
const params = selectedClientId ? `?client_id=${selectedClientId}` : '';
|
|
const res = await fetch(`/api/v1/admin/storage/purge${params}`, {
|
|
method: 'POST',
|
|
headers: getHeader(),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const totalFiles = (data.purged_files || 0) + (data.purged_images || 0);
|
|
toast.success(
|
|
`Purged ${totalFiles} files (${formatBytes(data.purged_bytes || 0)})`
|
|
);
|
|
load();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err.detail || 'Purge failed');
|
|
}
|
|
} catch {
|
|
toast.error('Purge failed');
|
|
} finally {
|
|
setPurging(false);
|
|
}
|
|
};
|
|
|
|
const toggleSelect = (id: string) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedIds.size === presentations.length) {
|
|
setSelectedIds(new Set());
|
|
} else {
|
|
setSelectedIds(new Set(presentations.map((p) => p.id)));
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<HamsterLoader size="md" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fadeIn">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold">Storage</h1>
|
|
<div className="flex items-center gap-3">
|
|
{/* Client selector for super_admin */}
|
|
{isSuperAdmin && clients.length > 0 && (
|
|
<Select
|
|
value={selectedClientId || '__all__'}
|
|
onValueChange={(v) => setSelectedClientId(v === '__all__' ? undefined : v)}
|
|
>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="All clients" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__all__">All clients</SelectItem>
|
|
{clients.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<Button variant="outline" size="sm" onClick={load}>
|
|
<RefreshCw className="w-4 h-4 mr-1" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-fadeIn">
|
|
<Card className="p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Presentations</p>
|
|
<p className="text-2xl font-bold mt-1">{summary.total_presentations}</p>
|
|
</div>
|
|
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
|
|
<FileText className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Export Files</p>
|
|
<p className="text-2xl font-bold mt-1">{summary.total_files}</p>
|
|
</div>
|
|
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
|
|
<FolderOpen className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Master Decks</p>
|
|
<p className="text-2xl font-bold mt-1">
|
|
{summary.total_master_decks}
|
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
({summary.master_deck_files} files)
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
|
|
<Layers className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Total Size</p>
|
|
<p className="text-2xl font-bold mt-1">
|
|
{formatBytes(summary.total_size_bytes + (summary.master_deck_size_bytes || 0))}
|
|
</p>
|
|
</div>
|
|
<div className="p-2 rounded-lg bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))]">
|
|
<HardDrive className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Soft-deleted notice + purge */}
|
|
{summary && summary.total_deleted > 0 && isSuperAdmin && (
|
|
<div className="flex items-center justify-between p-3 bg-[hsl(var(--warning)/0.08)] border border-[hsl(var(--warning)/0.3)] rounded-lg">
|
|
<div className="flex items-center gap-2 text-sm text-[hsl(var(--warning))]">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
<span>{summary.total_deleted} soft-deleted presentation(s) with files still on disk.</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handlePurge}
|
|
disabled={purging}
|
|
className="text-[hsl(var(--warning))] border-[hsl(var(--warning)/0.3)] hover:bg-[hsl(var(--warning)/0.08)]"
|
|
>
|
|
{purging ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Trash2 className="w-3.5 h-3.5 mr-1" />}
|
|
Purge Files
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk actions toolbar */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="flex items-center gap-3 p-3 bg-[hsl(var(--primary)/0.06)] border border-[hsl(var(--primary)/0.2)] rounded-lg">
|
|
<span className="text-sm text-[hsl(var(--primary))] font-medium">
|
|
{selectedIds.size} selected
|
|
</span>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setBulkDeleteOpen(true)}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
|
Delete Selected
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedIds(new Set())}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Presentations Table */}
|
|
<Card>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-[hsl(var(--surface))]">
|
|
<th className="p-3 w-10">
|
|
<button onClick={toggleSelectAll} className="text-muted-foreground hover:text-foreground">
|
|
{selectedIds.size === presentations.length && presentations.length > 0 ? (
|
|
<CheckSquare className="w-4 h-4" />
|
|
) : (
|
|
<Square className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</th>
|
|
<th className="text-left p-3 font-medium text-muted-foreground">Title</th>
|
|
<th className="text-left p-3 font-medium text-muted-foreground">Status</th>
|
|
<th className="text-left p-3 font-medium text-muted-foreground">Created</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">Files</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">Size</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{presentations.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
|
No presentations found.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
presentations.map((p) => (
|
|
<tr
|
|
key={p.id}
|
|
className={`border-b last:border-0 hover:bg-[hsl(var(--surface))] ${selectedIds.has(p.id) ? 'bg-[hsl(var(--primary)/0.05)]' : ''}`}
|
|
>
|
|
<td className="p-3">
|
|
<button onClick={() => toggleSelect(p.id)} className="text-muted-foreground hover:text-foreground">
|
|
{selectedIds.has(p.id) ? (
|
|
<CheckSquare className="w-4 h-4 text-[hsl(var(--primary))]" />
|
|
) : (
|
|
<Square className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</td>
|
|
<td className="p-3">
|
|
<span className="font-medium">{p.title || 'Untitled'}</span>
|
|
</td>
|
|
<td className="p-3">
|
|
<StatusBadge status={p.status as 'draft' | 'in_review' | 'approved'} />
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
|
|
</td>
|
|
<td className="p-3 text-right text-muted-foreground">{p.file_count}</td>
|
|
<td className="p-3 text-right text-muted-foreground">
|
|
{p.total_size_bytes > 0 ? formatBytes(p.total_size_bytes) : '—'}
|
|
</td>
|
|
<td className="p-3 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{p.has_export && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDownload(p.id)}
|
|
title="Download PPTX"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeleteTarget(p)}
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-[hsl(var(--error))]" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Delete Confirmation */}
|
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Presentation</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Are you sure you want to delete "{deleteTarget?.title || 'Untitled'}"?
|
|
Associated files will be cleaned up by the retention service.
|
|
</p>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Bulk Delete Confirmation */}
|
|
<Dialog open={bulkDeleteOpen} onOpenChange={(open) => !open && setBulkDeleteOpen(false)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {selectedIds.size} Presentations</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Are you sure you want to delete {selectedIds.size} selected presentations?
|
|
This action can be undone by an admin.
|
|
</p>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<Button variant="outline" onClick={() => setBulkDeleteOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleBulkDelete}>
|
|
Delete All
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|