ppt-tool/frontend/app/admin/storage/page.tsx
Vadym Samoilenko 5def8f9e84 Phase 7: Apply design system to all admin pages + fix test stubs
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>
2026-03-01 19:01:52 +00:00

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 &quot;{deleteTarget?.title || 'Untitled'}&quot;?
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>
);
}