recover-file-form.tsx102 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6interface Props {
7 apiOrigin: string;
8}
9
10/**
11 * Per-file admin recover control. POSTs
12 * /v1/admin/storage/projects/:id/files/:fileId/restore to un-delete a single
13 * soft-deleted file — the support path for "a customer emailed asking to
14 * restore file X". Takes a project id + file id by hand (the operator pastes
15 * them from the customer's message), shows the returned status on success and
16 * the error message on failure.
17 */
18export function RecoverFileForm({ apiOrigin }: Props) {
19 const router = useRouter();
20 const [projectId, setProjectId] = useState('');
21 const [fileId, setFileId] = useState('');
22 const [busy, setBusy] = useState(false);
23 const [error, setError] = useState<string | null>(null);
24 const [status, setStatus] = useState<string | null>(null);
25 const [, startTransition] = useTransition();
26
27 async function send() {
28 const pid = projectId.trim();
29 const fid = fileId.trim();
30 if (pid === '' || fid === '') {
31 setError('project id and file id are both required');
32 return;
33 }
34 setBusy(true);
35 setError(null);
36 setStatus(null);
37 try {
38 const res = await fetch(
39 `${apiOrigin}/v1/admin/storage/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(fid)}/restore`,
40 {
41 method: 'POST',
42 credentials: 'include',
43 headers: { 'content-type': 'application/json' },
44 },
45 );
46 if (!res.ok) {
47 const parsed = (await res.json().catch(() => null)) as { message?: string } | null;
48 throw new Error(parsed?.message || `recover failed: ${res.status}`);
49 }
50 const parsed = (await res.json().catch(() => null)) as { status?: string } | null;
51 setStatus(parsed?.status ?? 'restored');
52 startTransition(() => router.refresh());
53 } catch (err) {
54 setError(err instanceof Error ? err.message : 'recover failed');
55 } finally {
56 setBusy(false);
57 }
58 }
59
60 return (
61 <div className="flex flex-col gap-2">
62 <div className="flex flex-wrap items-end gap-2">
63 <label className="flex flex-col gap-0.5">
64 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">project id</span>
65 <input
66 type="text"
67 value={projectId}
68 onChange={(e) => setProjectId(e.target.value)}
69 placeholder="prj_…"
70 disabled={busy}
71 className="w-64 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
72 />
73 </label>
74 <label className="flex flex-col gap-0.5">
75 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">file id</span>
76 <input
77 type="text"
78 value={fileId}
79 onChange={(e) => setFileId(e.target.value)}
80 placeholder="file_…"
81 disabled={busy}
82 className="w-64 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
83 />
84 </label>
85 <button
86 type="button"
87 onClick={() => void send()}
88 disabled={busy}
89 className="rounded-md border border-[var(--color-primary)] bg-[var(--color-primary-subtle)] px-3 py-1 font-mono text-xs text-[var(--color-primary)] hover:bg-[var(--color-primary)]/15 disabled:opacity-50"
90 >
91 {busy ? 'recovering…' : 'recover'}
92 </button>
93 </div>
94 {status ? (
95 <p className="font-mono text-[10px] text-[var(--color-primary)]">recovered · {status}</p>
96 ) : null}
97 {error ? (
98 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
99 ) : null}
100 </div>
101 );
102}