Files
fofos-admin/web/MediaFolder.tsx
T
pastilhasandClaude Opus 5 003613872d provision <root>/media, beside the checkout
where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.

BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:

  - it must SURVIVE the checkout. deleting `website` and cloning it again is a
    normal thing to do when something is wrong with it, and it must never be the
    thing that destroys the client's photographs.
  - it must never be TRACKED. media in git is a repository nobody can clone a year
    from now, and a .gitignore is a promise somebody eventually breaks with
    `git add -f`.

both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.

NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.

`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.

no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:03:28 +01:00

103 lines
3.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleAlert, CircleCheck, CircleDashed, FolderPlus, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
/**
* `<root>/media` — where the images and the files live.
*
* Same row shape as the checkout above it, because they are the same kind of fact: a thing that either
* is or is not at a known path under the root, with one button that puts it there.
*
* No console. Making a directory either works or gives one line about why not, and a black transcript
* box for a single sentence would make a small thing look like a large one.
*
* What goes INSIDE is not decided yet — see `api/media.ts`. This provisions the directory and nothing
* else on purpose.
*/
type Status = { path: string; present: boolean; entries: number | null; blocked: boolean };
export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Status | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setStatus(await clientRef.current.get<Status>('/fofos-admin/_config/media'));
} catch {
// Owner-only. The block above already says so once, and twice is two refusals for one cause.
setStatus(null);
}
}, []);
// Re-read whenever the root moves — this describes `<root>/media`, and a stale answer is about a
// directory nobody is looking at any more.
useEffect(() => {
void load();
}, [load, rootPath]);
const create = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.post<{ ok: boolean; error?: string; status: Status }>(
'/fofos-admin/_config/media/create',
{},
);
setStatus(result.status);
if (!result.ok && result.error) setError(result.error);
} catch (failure) {
setError(readableError(failure));
void load();
} finally {
setBusy(false);
}
};
if (!status) return null;
const Icon = status.blocked ? CircleAlert : status.present ? CircleCheck : CircleDashed;
const tone = status.blocked ? 'text-destructive' : status.present ? 'text-success' : 'text-muted-foreground';
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-start gap-2">
<Icon className={`mt-0.5 size-3.5 shrink-0 ${tone}`} />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="font-mono text-xs break-all">{status.path}</p>
{status.blocked ? (
<p className="text-xs text-destructive">Something is already here, and it is not a directory.</p>
) : status.present ? (
<p className="text-xs text-muted-foreground">
{status.entries === null
? 'Ready.'
: status.entries === 0
? 'Ready — empty.'
: `Ready — ${status.entries} item${status.entries === 1 ? '' : 's'}.`}
</p>
) : (
<p className="text-xs text-muted-foreground">Not created yet.</p>
)}
</div>
{!status.present && (
<Button size="sm" className="shrink-0" onClick={() => void create()} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <FolderPlus className="size-3.5" />}
Create
</Button>
)}
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
};