feat: initial Signage 0.1.0
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user