style: satisfy housekeeping biome gate
This commit is contained in:
1 parent
d1160eb65a
commit
08358b8aa4
62 files changed
+2024
-773
No files matched your search
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import {
|
||||
CONTENT_COMMAND_IDS,
|
||||
createContentCommands,
|
||||
} from "./content-commands";
|
||||
import { CONTENT_COMMAND_IDS, createContentCommands } from "./content-commands";
|
||||
|
||||
const expected = [
|
||||
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
|
||||
@@ -41,11 +38,7 @@ const expected = [
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||
[
|
||||
"content.engagement.prefix.change",
|
||||
"prefix.change",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||
[
|
||||
"content.engagement.prefix-blacklist.change",
|
||||
"prefix-blacklist.change",
|
||||
|
||||
@@ -14,33 +14,93 @@ const CONTENT_COMMAND_DEFINITIONS = [
|
||||
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
|
||||
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
|
||||
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
|
||||
["content.engagement.event-type.change", "event-type.change", PERMS.EVENTS_EDIT],
|
||||
[
|
||||
"content.engagement.event-type.change",
|
||||
"event-type.change",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
|
||||
["content.engagement.event-prize.change", "event-prize.change", PERMS.EVENTS_EDIT],
|
||||
["content.engagement.event-winner.add", "event-winner.add", PERMS.EVENTS_EDIT],
|
||||
[
|
||||
"content.engagement.event-prize.change",
|
||||
"event-prize.change",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.event-winner.add",
|
||||
"event-winner.add",
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||
["content.engagement.poll-question.change", "poll-question.change", PERMS.POLLS_EDIT],
|
||||
[
|
||||
"content.engagement.poll-question.change",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
],
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
["content.editorial.navigation.update", "navigation.update", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.editorial.navigation.update",
|
||||
"navigation.update",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||
["content.engagement.prefix-blacklist.change", "prefix-blacklist.change", PERMS.PREFIXES_EDIT],
|
||||
["content.engagement.prefix-settings.update", "prefix-settings.update", PERMS.PREFIXES_EDIT],
|
||||
[
|
||||
"content.engagement.prefix-blacklist.change",
|
||||
"prefix-blacklist.change",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.prefix-settings.update",
|
||||
"prefix-settings.update",
|
||||
PERMS.PREFIXES_EDIT,
|
||||
],
|
||||
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
|
||||
["content.editorial.writeable-box.change", "writeable-box.change", PERMS.PAGES_EDIT],
|
||||
["content.help.email-template.change", "email-template.change", PERMS.PAGES_EDIT],
|
||||
[
|
||||
"content.editorial.writeable-box.change",
|
||||
"writeable-box.change",
|
||||
PERMS.PAGES_EDIT,
|
||||
],
|
||||
[
|
||||
"content.help.email-template.change",
|
||||
"email-template.change",
|
||||
PERMS.PAGES_EDIT,
|
||||
],
|
||||
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.theme.apply-preset", "theme.apply-preset", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.theme.custom-change", "theme.custom-change", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.theme.apply-custom", "theme.apply-custom", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.brand.theme.apply-preset",
|
||||
"theme.apply-preset",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.brand.theme.custom-change",
|
||||
"theme.custom-change",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.brand.theme.apply-custom",
|
||||
"theme.apply-custom",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
|
||||
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
|
||||
["content.localization.cms.save", "translation.cms.save", PERMS.SETTINGS_EDIT],
|
||||
["content.localization.client.save", "translation.client.save", PERMS.SETTINGS_EDIT],
|
||||
["content.localization.emulator.save", "translation.emulator.save", PERMS.SETTINGS_EDIT],
|
||||
[
|
||||
"content.localization.cms.save",
|
||||
"translation.cms.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.localization.client.save",
|
||||
"translation.client.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.localization.emulator.save",
|
||||
"translation.emulator.save",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
] as const;
|
||||
|
||||
export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map(
|
||||
|
||||
@@ -2,27 +2,142 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentBrandPage({ context, result, routeId }: ContentPageProps) {
|
||||
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId === "content.brand.theme" ? <>
|
||||
<ContentCommandForm commandId="content.brand.theme.update" buttonLabel="Save theme values" input={{}} fields={[{ name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason />
|
||||
<ContentCommandForm commandId="content.brand.theme.apply-preset" buttonLabel="Apply preset" input={{}} fields={[{ name: "preset", label: "Preset", type: "text", required: true, maxLength: 100 }]} requiresReason />
|
||||
<ContentCommandForm commandId="content.brand.theme.custom-change" buttonLabel="Save custom theme" input={{ action: "update" }} fields={[{ name: "id", label: "Theme ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 100 }, { name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason />
|
||||
<ContentCommandForm commandId="content.brand.theme.apply-custom" buttonLabel="Apply custom theme" input={{}} fields={[{ name: "id", label: "Theme ID", type: "identifier", required: true }]} requiresReason />
|
||||
</> : null}
|
||||
{routeId === "content.brand.favicon" ? <>
|
||||
<ContentCommandForm commandId="content.brand.favicon.save" buttonLabel="Save favicon" input={{}} fields={[{ name: "file", label: "Favicon", type: "file", required: true }]} requiresReason />
|
||||
<ContentCommandForm commandId="content.brand.favicon.delete" buttonLabel="Delete favicon" input={{}} requiresReason />
|
||||
<ContentCommandForm commandId="content.brand.logo.save" buttonLabel="Save logo" input={{}} fields={[{ name: "file", label: "Logo", type: "file", required: true }]} requiresReason />
|
||||
</> : null}
|
||||
</div> : null;
|
||||
return <ContentPageFrame title="Brand" description="Manage theme, favicon, and logo assets." result={result} forms={forms} />;
|
||||
export function ContentBrandPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const forms = context.has(PERMS.SETTINGS_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId === "content.brand.theme" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.update"
|
||||
buttonLabel="Save theme values"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "values",
|
||||
label: "Theme values JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.apply-preset"
|
||||
buttonLabel="Apply preset"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "preset",
|
||||
label: "Preset",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.custom-change"
|
||||
buttonLabel="Save custom theme"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Theme ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "values",
|
||||
label: "Theme values JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.theme.apply-custom"
|
||||
buttonLabel="Apply custom theme"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Theme ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{routeId === "content.brand.favicon" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.favicon.save"
|
||||
buttonLabel="Save favicon"
|
||||
input={{}}
|
||||
fields={[
|
||||
{ name: "file", label: "Favicon", type: "file", required: true },
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.favicon.delete"
|
||||
buttonLabel="Delete favicon"
|
||||
input={{}}
|
||||
requiresReason
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.brand.logo.save"
|
||||
buttonLabel="Save logo"
|
||||
input={{}}
|
||||
fields={[
|
||||
{ name: "file", label: "Logo", type: "file", required: true },
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Brand"
|
||||
description="Manage theme, favicon, and logo assets."
|
||||
result={result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentBrandPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.brand.theme", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||
return <ContentBrandPage context={input.context} result={result} routeId={routeId} />;
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.brand.theme",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentBrandPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
import type { ContentQueryData } from "../queries/content-queries";
|
||||
|
||||
export interface ContentPageProps {
|
||||
@@ -65,7 +63,9 @@ export function ContentPageFrame({
|
||||
}
|
||||
|
||||
export function parseContentListInput(
|
||||
searchParams: Readonly<Record<string, string | readonly string[] | undefined>>,
|
||||
searchParams: Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>,
|
||||
) {
|
||||
const first = (value: string | readonly string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentEditorialPage({ context, result, routeId }: ContentPageProps) {
|
||||
export function ContentEditorialPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const canNews = context.has(PERMS.NEWS_EDIT);
|
||||
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||
const canSettings = context.has(PERMS.SETTINGS_EDIT);
|
||||
@@ -23,11 +27,25 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
|
||||
<ContentCommandForm
|
||||
commandId="content.editorial.article.change"
|
||||
buttonLabel="Save article"
|
||||
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
|
||||
input={{
|
||||
action: routeId.endsWith("create") ? "create" : "update",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "Article ID", type: "identifier" },
|
||||
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
|
||||
{ name: "content", label: "Body", type: "textarea", required: true, maxLength: 100_000 },
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
label: "Body",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 100_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
@@ -36,7 +54,15 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
|
||||
commandId="content.editorial.navigation.update"
|
||||
buttonLabel="Save navigation"
|
||||
input={{}}
|
||||
fields={[{ name: "items", label: "Navigation JSON", type: "json", required: true, maxLength: 20_000 }]}
|
||||
fields={[
|
||||
{
|
||||
name: "items",
|
||||
label: "Navigation JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId === "content.editorial.tags" ? (
|
||||
@@ -46,7 +72,13 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Tag ID", type: "identifier" },
|
||||
{ name: "name", label: "Name", type: "text", required: true, maxLength: 100 },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
@@ -57,8 +89,19 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Box ID", type: "identifier" },
|
||||
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
|
||||
{ name: "content", label: "Content", type: "textarea", maxLength: 20_000 },
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
label: "Content",
|
||||
type: "textarea",
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
@@ -75,5 +118,11 @@ export async function renderContentEditorialPage(input: HousekeepingPageInput) {
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return <ContentEditorialPage context={input.context} result={result} routeId={routeId} />;
|
||||
return (
|
||||
<ContentEditorialPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,96 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentHelpPage({ context, result, routeId }: ContentPageProps) {
|
||||
const forms = context.has(PERMS.PAGES_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId?.includes("question") ? <ContentCommandForm commandId="content.help.question.change" buttonLabel="Save help question" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Question ID", type: "identifier" }, { name: "name", label: "Question", type: "text", required: true, maxLength: 255 }, { name: "answer", label: "Answer", type: "textarea", required: true, maxLength: 20_000 }]} /> : null}
|
||||
{routeId === "content.help.email-templates" ? <ContentCommandForm commandId="content.help.email-template.change" buttonLabel="Save email template" input={{ action: "update" }} fields={[{ name: "id", label: "Template ID", type: "identifier", required: true }, { name: "subject", label: "Subject", type: "text", required: true, maxLength: 255 }, { name: "body", label: "Body", type: "textarea", required: true, maxLength: 100_000 }]} /> : null}
|
||||
</div> : null;
|
||||
return <ContentPageFrame title="Help content" description="Manage help questions and email templates." result={result} forms={forms} />;
|
||||
export function ContentHelpPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const forms = context.has(PERMS.PAGES_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId?.includes("question") ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.help.question.change"
|
||||
buttonLabel="Save help question"
|
||||
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Question ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Question",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "answer",
|
||||
label: "Answer",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === "content.help.email-templates" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.help.email-template.change"
|
||||
buttonLabel="Save email template"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Template ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "subject",
|
||||
label: "Subject",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
label: "Body",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 100_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Help content"
|
||||
description="Manage help questions and email templates."
|
||||
result={result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentHelpPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.help.questions", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||
return <ContentHelpPage context={input.context} result={result} routeId={routeId} />;
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.help.questions",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentHelpPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,17 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentMediaPage({ context, result, routeId }: ContentPageProps) {
|
||||
export function ContentMediaPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||
const canBanners = context.has(PERMS.BANNERS_EDIT);
|
||||
return (
|
||||
@@ -12,21 +20,108 @@ export function ContentMediaPage({ context, result, routeId }: ContentPageProps)
|
||||
title="Media"
|
||||
description="Manage photos, uploaded media, banners, and advertisements."
|
||||
result={result}
|
||||
forms={<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canPages && routeId === "content.media.photos" ? <ContentCommandForm commandId="content.media.photo.delete" buttonLabel="Delete photo" input={{}} fields={[{ name: "id", label: "Photo ID", type: "identifier", required: true }]} /> : null}
|
||||
{canPages && routeId === "content.media.library" ? <>
|
||||
<ContentCommandForm commandId="content.media.asset.upload" buttonLabel="Upload media" input={{}} fields={[{ name: "file", label: "Media file", type: "file", required: true }]} />
|
||||
<ContentCommandForm commandId="content.media.asset.delete" buttonLabel="Delete media" input={{}} fields={[{ name: "filename", label: "Filename", type: "text", required: true, maxLength: 255 }]} />
|
||||
</> : null}
|
||||
{canBanners && routeId === "content.media.banners" ? <ContentCommandForm commandId="content.media.banner.change" buttonLabel="Save banner" input={{ action: "update" }} fields={[{ name: "id", label: "Banner ID", type: "identifier" }, { name: "title", label: "Title", type: "text", maxLength: 255 }]} /> : null}
|
||||
{canPages && routeId?.includes("ad") ? <ContentCommandForm commandId="content.media.ad.change" buttonLabel="Save advertisement" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Advertisement ID", type: "identifier" }, { name: "image", label: "Image path", type: "text", required: true, maxLength: 500 }, { name: "url", label: "Destination", type: "text", maxLength: 1000 }]} /> : null}
|
||||
</div>}
|
||||
forms={
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canPages && routeId === "content.media.photos" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.media.photo.delete"
|
||||
buttonLabel="Delete photo"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Photo ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId === "content.media.library" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.media.asset.upload"
|
||||
buttonLabel="Upload media"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "file",
|
||||
label: "Media file",
|
||||
type: "file",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.media.asset.delete"
|
||||
buttonLabel="Delete media"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "filename",
|
||||
label: "Filename",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canBanners && routeId === "content.media.banners" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.media.banner.change"
|
||||
buttonLabel="Save banner"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "Banner ID", type: "identifier" },
|
||||
{ name: "title", label: "Title", type: "text", maxLength: 255 },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId?.includes("ad") ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.media.ad.change"
|
||||
buttonLabel="Save advertisement"
|
||||
input={{
|
||||
action: routeId.endsWith("create") ? "create" : "update",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "Advertisement ID", type: "identifier" },
|
||||
{
|
||||
name: "image",
|
||||
label: "Image path",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
name: "url",
|
||||
label: "Destination",
|
||||
type: "text",
|
||||
maxLength: 1000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentMediaPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.media.photos", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||
return <ContentMediaPage context={input.context} result={result} routeId={routeId} />;
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.media.photos",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentMediaPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
} from "./routes";
|
||||
|
||||
const expected = [
|
||||
["content.editorial.articles", "/ase/content/editorial/articles", "editorial"],
|
||||
[
|
||||
"content.editorial.articles",
|
||||
"/ase/content/editorial/articles",
|
||||
"editorial",
|
||||
],
|
||||
[
|
||||
"content.editorial.article-create",
|
||||
"/ase/content/editorial/articles/new",
|
||||
@@ -57,16 +61,8 @@ const expected = [
|
||||
"engagement",
|
||||
],
|
||||
["content.help.questions", "/ase/content/help/questions", "help"],
|
||||
[
|
||||
"content.help.question-create",
|
||||
"/ase/content/help/questions/new",
|
||||
"help",
|
||||
],
|
||||
[
|
||||
"content.help.question-detail",
|
||||
"/ase/content/help/questions/:id",
|
||||
"help",
|
||||
],
|
||||
["content.help.question-create", "/ase/content/help/questions/new", "help"],
|
||||
["content.help.question-detail", "/ase/content/help/questions/:id", "help"],
|
||||
["content.editorial.tags", "/ase/content/editorial/tags", "editorial"],
|
||||
[
|
||||
"content.engagement.prefixes",
|
||||
@@ -78,14 +74,14 @@ const expected = [
|
||||
"/ase/content/editorial/writeable-boxes",
|
||||
"editorial",
|
||||
],
|
||||
[
|
||||
"content.help.email-templates",
|
||||
"/ase/content/help/email-templates",
|
||||
"help",
|
||||
],
|
||||
["content.help.email-templates", "/ase/content/help/email-templates", "help"],
|
||||
["content.brand.theme", "/ase/content/brand/theme", "brand"],
|
||||
["content.brand.favicon", "/ase/content/brand/favicon", "brand"],
|
||||
["content.localization.overview", "/ase/content/localization", "localization"],
|
||||
[
|
||||
"content.localization.overview",
|
||||
"/ase/content/localization",
|
||||
"localization",
|
||||
],
|
||||
[
|
||||
"content.localization.client",
|
||||
"/ase/content/localization/client",
|
||||
|
||||
@@ -64,9 +64,11 @@ function contentRoute(
|
||||
}
|
||||
|
||||
export const CONTENT_ROUTES = [
|
||||
contentRoute("content.editorial.articles", "/ase/content/editorial/articles", [
|
||||
PERMS.NEWS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.editorial.articles",
|
||||
"/ase/content/editorial/articles",
|
||||
[PERMS.NEWS_VIEW],
|
||||
),
|
||||
contentRoute(
|
||||
"content.editorial.article-create",
|
||||
"/ase/content/editorial/articles/new",
|
||||
@@ -95,11 +97,9 @@ export const CONTENT_ROUTES = [
|
||||
contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [
|
||||
PERMS.PAGES_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.engagement.events",
|
||||
"/ase/content/engagement/events",
|
||||
[PERMS.EVENTS_VIEW],
|
||||
),
|
||||
contentRoute("content.engagement.events", "/ase/content/engagement/events", [
|
||||
PERMS.EVENTS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.engagement.event-create",
|
||||
"/ase/content/engagement/events/create",
|
||||
@@ -115,11 +115,9 @@ export const CONTENT_ROUTES = [
|
||||
"/ase/content/engagement/events/:id",
|
||||
[PERMS.EVENTS_EDIT],
|
||||
),
|
||||
contentRoute(
|
||||
"content.engagement.polls",
|
||||
"/ase/content/engagement/polls",
|
||||
[PERMS.POLLS_VIEW],
|
||||
),
|
||||
contentRoute("content.engagement.polls", "/ase/content/engagement/polls", [
|
||||
PERMS.POLLS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.engagement.poll-create",
|
||||
"/ase/content/engagement/polls/create",
|
||||
@@ -167,11 +165,9 @@ export const CONTENT_ROUTES = [
|
||||
contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.localization.overview",
|
||||
"/ase/content/localization",
|
||||
[PERMS.SETTINGS_VIEW],
|
||||
),
|
||||
contentRoute("content.localization.overview", "/ase/content/localization", [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
]),
|
||||
contentRoute(
|
||||
"content.localization.client",
|
||||
"/ase/content/localization/client",
|
||||
|
||||
@@ -11,12 +11,8 @@ import type { ContentSearchCandidate } from "./search";
|
||||
type ContentSearchKind = "articles" | "events" | "media" | "help";
|
||||
|
||||
const SEARCH_ROUTES = {
|
||||
articles: [
|
||||
["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)],
|
||||
],
|
||||
events: [
|
||||
["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)],
|
||||
],
|
||||
articles: [["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)]],
|
||||
events: [["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)]],
|
||||
media: [
|
||||
["content.media.photos", anyCapability(PERMS.PAGES_VIEW)],
|
||||
["content.media.library", anyCapability(PERMS.PAGES_VIEW)],
|
||||
|
||||
@@ -48,7 +48,9 @@ export const CONTENT_MIXED_OPERATIONS = [
|
||||
type TransactionToken = unknown;
|
||||
|
||||
export interface ContentProductionMutationDependencies {
|
||||
transaction<T>(run: (transaction: TransactionToken) => Promise<T>): Promise<T>;
|
||||
transaction<T>(
|
||||
run: (transaction: TransactionToken) => Promise<T>,
|
||||
): Promise<T>;
|
||||
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
|
||||
executeOperation(
|
||||
operation: ContentMutationOperation,
|
||||
@@ -111,7 +113,8 @@ async function writeOutcomeOrMarkUnavailable(
|
||||
} catch {
|
||||
const completion = {
|
||||
status: "partial" as const,
|
||||
external: outcome === "partial" ? ("failed" as const) : ("completed" as const),
|
||||
external:
|
||||
outcome === "partial" ? ("failed" as const) : ("completed" as const),
|
||||
audit: "unavailable" as const,
|
||||
};
|
||||
return { ...snapshot, completion };
|
||||
|
||||
@@ -24,9 +24,8 @@ function context(
|
||||
describe("Content mutation service authority", () => {
|
||||
it("rehydrates server authority and rejects forged actor identity", async () => {
|
||||
const adapter = { execute: vi.fn() };
|
||||
const service = createContentMutationService(
|
||||
adapter,
|
||||
async () => context([PERMS.NEWS_EDIT], 42),
|
||||
const service = createContentMutationService(adapter, async () =>
|
||||
context([PERMS.NEWS_EDIT], 42),
|
||||
);
|
||||
|
||||
const result = await service.execute(
|
||||
@@ -44,9 +43,8 @@ describe("Content mutation service authority", () => {
|
||||
|
||||
it("requires the exact operation ACL even after dispatcher authorization", async () => {
|
||||
const adapter = { execute: vi.fn() };
|
||||
const service = createContentMutationService(
|
||||
adapter,
|
||||
async () => context([PERMS.NEWS_EDIT]),
|
||||
const service = createContentMutationService(adapter, async () =>
|
||||
context([PERMS.NEWS_EDIT]),
|
||||
);
|
||||
|
||||
const result = await service.execute(
|
||||
@@ -67,9 +65,8 @@ describe("Content mutation service authority", () => {
|
||||
before: null,
|
||||
after: { actorId: mutationContext.capability.actor.id },
|
||||
}));
|
||||
const service = createContentMutationService(
|
||||
{ execute },
|
||||
async () => context(Object.values(PERMS)),
|
||||
const service = createContentMutationService({ execute }, async () =>
|
||||
context(Object.values(PERMS)),
|
||||
);
|
||||
const invocation = createContentMutationInvocation(
|
||||
{ id: 42 },
|
||||
|
||||
@@ -125,9 +125,7 @@ const OPERATION_PERMISSION = Object.freeze({
|
||||
"translation.emulator.save": PERMS.SETTINGS_EDIT,
|
||||
} satisfies Record<ContentMutationOperation, string>);
|
||||
|
||||
export function contentMutationCapability(
|
||||
operation: ContentMutationOperation,
|
||||
) {
|
||||
export function contentMutationCapability(operation: ContentMutationOperation) {
|
||||
return anyCapability(OPERATION_PERMISSION[operation]);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user