diff --git a/api/media.ts b/api/media.ts new file mode 100644 index 0000000..0d42be0 --- /dev/null +++ b/api/media.ts @@ -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 ── + * + * `/media`, a sibling of `/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 — `/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 => { + const settings = await readSettings(email); + return join(settings.rootPath, DIR_NAME); +}; + +export async function readMediaStatus(email: string): Promise { + 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 }; + } +} diff --git a/api/router.ts b/api/router.ts index 56211bf..d0f7916 100644 --- a/api/router.ts +++ b/api/router.ts @@ -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. * diff --git a/manifest.ts b/manifest.ts index a617a93..80e950f 100644 --- a/manifest.ts +++ b/manifest.ts @@ -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', diff --git a/web/MediaFolder.tsx b/web/MediaFolder.tsx new file mode 100644 index 0000000..5c4c5c3 --- /dev/null +++ b/web/MediaFolder.tsx @@ -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'; + +/** + * `/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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + setStatus(await clientRef.current.get('/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 `/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 ( +
+
+ +
+

{status.path}

+ + {status.blocked ? ( +

Something is already here, and it is not a directory.

+ ) : status.present ? ( +

+ {status.entries === null + ? 'Ready.' + : status.entries === 0 + ? 'Ready — empty.' + : `Ready — ${status.entries} item${status.entries === 1 ? '' : 's'}.`} +

+ ) : ( +

Not created yet.

+ )} +
+ + {!status.present && ( + + )} +
+ + {error &&

{error}

} +
+ ); +}; diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx index b62b34f..96a463a 100644 --- a/web/RootLocationBlock.tsx +++ b/web/RootLocationBlock.tsx @@ -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 && } + {/* 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 && } + {/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it gets the full width. */}