227 lines
8.7 KiB
TypeScript
227 lines
8.7 KiB
TypeScript
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>
|
||
);
|
||
}
|