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,77 @@
|
||||
import { mkdir, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { readSettings } from './settings';
|
||||
|
||||
/**
|
||||
* The media directory under the root — where the images and files live.
|
||||
*
|
||||
* ── BESIDE the checkout, never inside it ──
|
||||
*
|
||||
* `<root>/media`, a sibling of `<root>/website`. That is the whole 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 a thing that destroys the client's photos.
|
||||
* - 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`.
|
||||
*
|
||||
* The two live under one root so a backup of the root is the whole site — the code and the things it
|
||||
* serves — which is the property that makes the root worth being a setting at all.
|
||||
*
|
||||
* ── Nothing about its INSIDES is decided here ──
|
||||
*
|
||||
* This creates the directory and reports whether it is there. What goes in it, how it is laid out, and
|
||||
* who writes to it are not settled, and inventing a folder tree now would be deciding that here — in
|
||||
* the file least able to change its mind later, because directories on disk are not a migration you
|
||||
* can run.
|
||||
*/
|
||||
|
||||
const DIR_NAME = 'media';
|
||||
|
||||
export type MediaStatus = {
|
||||
/** Absolute — `<root>/media`. */
|
||||
path: string;
|
||||
present: boolean;
|
||||
/** How many things are directly inside. Null when it is not there. */
|
||||
entries: number | null;
|
||||
/** It exists but is not a directory — a file in the way, which `mkdir` would fail on. */
|
||||
blocked: boolean;
|
||||
};
|
||||
|
||||
export const mediaPath = async (email: string): Promise<string> => {
|
||||
const settings = await readSettings(email);
|
||||
return join(settings.rootPath, DIR_NAME);
|
||||
};
|
||||
|
||||
export async function readMediaStatus(email: string): Promise<MediaStatus> {
|
||||
const path = await mediaPath(email);
|
||||
|
||||
const info = await stat(path).catch(() => null);
|
||||
if (!info) return { path, present: false, entries: null, blocked: false };
|
||||
if (!info.isDirectory()) return { path, present: true, entries: null, blocked: true };
|
||||
|
||||
const entries = await readdir(path).catch(() => null);
|
||||
return { path, present: true, entries: entries?.length ?? null, blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make it.
|
||||
*
|
||||
* `recursive`, so it also creates the root when the root is still only a default — saving a root does
|
||||
* not create anything, and provisioning into a path that does not exist yet is the ordinary first run
|
||||
* rather than an error worth reporting.
|
||||
*
|
||||
* Idempotent by the same flag: pressing it on a directory that already exists changes nothing and
|
||||
* fails at nothing, which is what makes it safe to offer next to a status that might be stale.
|
||||
*/
|
||||
export async function createMediaDir(email: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const status = await readMediaStatus(email);
|
||||
if (status.blocked) return { ok: false, error: `${status.path} exists and is not a directory.` };
|
||||
|
||||
try {
|
||||
await mkdir(status.path, { recursive: true });
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
|
||||
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
|
||||
import { readSettings, saveRootPath } from './settings';
|
||||
import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website';
|
||||
import { readMediaStatus, createMediaDir } from './media';
|
||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||
|
||||
/**
|
||||
@@ -211,6 +212,22 @@ router.post('/_config/website/update', async (ctx) => {
|
||||
return ctx.json({ ...result, status: await readWebsiteStatus(user.email) }, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
/**
|
||||
* The media directory beside the checkout. Same `/_config` prefix, so the same owner-only rule.
|
||||
*/
|
||||
router.get('/_config/media', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json(await readMediaStatus(user.email));
|
||||
});
|
||||
|
||||
router.post('/_config/media/create', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const result = await createMediaDir(user.email);
|
||||
// The status is re-read either way, so what the panel shows is what is on disk rather than what was
|
||||
// asked for.
|
||||
return ctx.json({ ...result, status: await readMediaStatus(user.email) }, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
/**
|
||||
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
|
||||
*
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
||||
*/
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.29.13',
|
||||
version: '2026.08.29.14',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Fofos',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { readableError } from './errors';
|
||||
import { DirectoryPicker } from './DirectoryPicker';
|
||||
import { Block } from './Block';
|
||||
import { WebsiteCheckout } from './WebsiteCheckout';
|
||||
import { MediaFolder } from './MediaFolder';
|
||||
import { useFlash } from './useFlash';
|
||||
|
||||
/**
|
||||
@@ -164,6 +165,10 @@ export const RootLocationBlock = () => {
|
||||
describes what is SAVED, and a half-typed path has nothing at it. */}
|
||||
{!editing && <WebsiteCheckout rootPath={saved.rootPath} />}
|
||||
|
||||
{/* Beside the checkout, under the same root — a backup of the root is then the whole site, the
|
||||
code and the things it serves. Hidden while editing for the same reason as the checkout. */}
|
||||
{!editing && <MediaFolder rootPath={saved.rootPath} />}
|
||||
|
||||
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
|
||||
gets the full width. */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
|
||||
Reference in New Issue
Block a user