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>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user