feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui";
import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react";
import {
createUploadQueue,
type UploadFile,
type UploadItem,
type UploadQueue,
} from "../../browser/uploads.js";
import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js";
import type { ImageRecord, SettingsResponse } from "../types.js";
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
/**
* The native image library and its upload queue.
*
* The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty
* individually bounded requests, a few at a time. The page stays responsive because only a few are
* in flight; one bad file fails alone; and the operator learns about a file that is too large or of
* the wrong type before a byte is sent, while the server still checks both again.
*/
export function ImagesPanel({
api,
settings,
canManage,
}: {
readonly api: SignageApi;
readonly settings: SettingsResponse;
readonly canManage: boolean;
}): ReactElement {
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(undefined);
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
const rename = useAction(() => {
setRenaming(undefined);
images.reload();
});
const remove = useAction(() => images.reload());
const queue = useMemo(() => {
const created = createUploadQueue({
concurrency: settings.uploads.concurrency,
maxBytes: settings.uploads.maxBytes,
maxFiles: settings.uploads.batchMaxFiles,
onChange: (items) => setUploads(items),
upload: async (file) => {
// One image per request, with the label in the query string so the body is exactly the
// bytes. The server streams and counts them; nothing here buffers a second copy.
const query = new URLSearchParams({ displayName: file.name, filename: file.name });
await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type);
},
});
queueRef.current = created;
return created;
}, [
api,
settings.uploads.batchMaxFiles,
settings.uploads.concurrency,
settings.uploads.maxBytes,
]);
const onFiles = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []) as unknown as UploadFile[];
if (files.length === 0) {
return;
}
queue.enqueue(files);
void queue.drain().then(() => images.reload());
event.target.value = "";
},
[images, queue],
);
const finished = uploads.filter(
(item) => item.status !== "queued" && item.status !== "uploading",
);
return (
<Panel
actions={
canManage ? (
<label className="signage-upload-trigger">
<span className="ek-button ek-button--primary">Add images</span>
<input
accept="image/png,image/jpeg,image/webp"
className="signage-upload-input"
data-testid="signage-upload-input"
multiple
onChange={onFiles}
type="file"
/>
</label>
) : undefined
}
description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`}
title="Images"
>
{uploads.length === 0 ? undefined : (
<div className="signage-uploads" data-testid="signage-uploads">
<ul className="ek-list">
{uploads.map((item) => (
<li className="ek-list__item signage-row" key={item.id}>
<span className="signage-row__name">{item.name}</span>
<Badge
tone={
item.status === "done"
? "success"
: item.status === "failed" || item.status === "rejected"
? "danger"
: "info"
}
>
{item.status}
</Badge>
{item.message === undefined ? undefined : (
<span className="signage-uploads__message">{item.message}</span>
)}
</li>
))}
</ul>
{finished.length === 0 ? undefined : (
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
)}
</div>
)}
{renderLoadable(
images.state,
(value) =>
value.images.length === 0 ? (
<EmptyState
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
title="No images yet"
/>
) : (
<ul className="signage-image-grid" data-testid="signage-image-grid">
{value.images.map((image) => {
const thumbnail = image.renditions.find(
(rendition) => rendition.kind === "thumbnail",
);
return (
<li className="signage-image" key={image.id}>
{thumbnail === undefined ? (
<div className="signage-image__missing">No preview</div>
) : (
<img
alt={image.displayName}
className="signage-image__thumbnail"
height={thumbnail.height}
src={thumbnail.url}
width={thumbnail.width}
/>
)}
<div className="signage-image__meta">
{renaming?.id === image.id ? (
<Field label="Name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setRenaming({ id: image.id, value: event.target.value })
}
value={renaming.value}
/>
)}
</Field>
) : (
<p className="signage-image__name">{image.displayName}</p>
)}
<p className="signage-image__detail">
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
</p>
{image.expiresAt === null ? undefined : (
<Badge className="signage-image__expiry" tone="warning">
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
</Badge>
)}
</div>
{canManage ? (
<div className="signage-image__actions">
{renaming?.id === image.id ? (
<>
<Button
disabled={rename.pending}
onClick={() =>
rename.run(() =>
api.send("PATCH", `images/${image.id}`, {
displayName: renaming.value,
}),
)
}
variant="primary"
>
Save
</Button>
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
</>
) : (
<Button
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
>
Rename
</Button>
)}
<Button
disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
variant="danger"
>
Delete
</Button>
</div>
) : undefined}
</li>
);
})}
</ul>
),
{ label: "Loading images…", onRetry: images.reload },
)}
<FailureNote failure={rename.failure ?? remove.failure} />
</Panel>
);
}