100 lines
2.9 KiB
TypeScript
100 lines
2.9 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type { HousekeepingResult } from "../../../foundation/contracts";
|
|
import type { ContentQueryData } from "../queries/content-queries";
|
|
|
|
export interface ContentPageProps {
|
|
readonly title?: string;
|
|
readonly search?: string;
|
|
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
|
|
readonly result?: HousekeepingResult<ContentQueryData>;
|
|
readonly routeId: import("../routes").ContentRouteId | null;
|
|
}
|
|
|
|
export function ContentPageFrame({
|
|
title,
|
|
description,
|
|
result,
|
|
forms,
|
|
}: {
|
|
readonly title: string;
|
|
readonly description: string;
|
|
readonly result?: HousekeepingResult<ContentQueryData>;
|
|
readonly forms?: ReactNode;
|
|
}) {
|
|
if (!result) {
|
|
return (
|
|
<section data-housekeeping-state="loading">
|
|
<h1>{title}</h1>
|
|
<p>{description}</p>
|
|
<p>Loading content…</p>
|
|
</section>
|
|
);
|
|
}
|
|
if (!result.ok) {
|
|
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
|
|
return (
|
|
<section data-housekeeping-state={state} role="alert">
|
|
<h1>{title}</h1>
|
|
<p>{result.error.messageKey}</p>
|
|
</section>
|
|
);
|
|
}
|
|
const state = result.data.items.length === 0 ? "empty" : "ready";
|
|
return (
|
|
<section data-housekeeping-state={state} className="space-y-4">
|
|
<header>
|
|
<h1>{title}</h1>
|
|
<p>{description}</p>
|
|
</header>
|
|
{forms}
|
|
{result.data.items.length === 0 ? (
|
|
<p>No matching content.</p>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--admin-border)]">
|
|
{result.data.items.map((item) => (
|
|
<li key={item.id} className="py-2">
|
|
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
|
|
{item.status ? <span> — {item.status}</span> : null}
|
|
{item.description ? <p>{item.description}</p> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function parseContentListInput(
|
|
searchParams: Readonly<
|
|
Record<string, string | readonly string[] | undefined>
|
|
>,
|
|
) {
|
|
const first = (value: string | readonly string[] | undefined) =>
|
|
Array.isArray(value) ? value[0] : value;
|
|
const text = (value: string | readonly string[] | undefined, limit: number) =>
|
|
String(first(value) ?? "")
|
|
.normalize("NFC")
|
|
.trim()
|
|
.slice(0, limit);
|
|
const bounded = (
|
|
value: string | readonly string[] | undefined,
|
|
fallback: number,
|
|
minimum: number,
|
|
maximum: number,
|
|
) => {
|
|
const parsed = Number(first(value));
|
|
return Number.isSafeInteger(parsed)
|
|
? Math.min(maximum, Math.max(minimum, parsed))
|
|
: fallback;
|
|
};
|
|
return {
|
|
search: text(searchParams.search, 128),
|
|
status: text(searchParams.status, 32).toLocaleLowerCase(),
|
|
pageSize: bounded(searchParams.pageSize, 25, 1, 100),
|
|
offset: bounded(searchParams.offset, 0, 0, 100_000),
|
|
responseQuestionId: text(searchParams.responseQuestionId, 32),
|
|
responsePageSize: bounded(searchParams.responsePageSize, 25, 1, 50),
|
|
responseOffset: bounded(searchParams.responseOffset, 0, 0, 100_000),
|
|
};
|
|
}
|