feat(editorial): validate publications and preserve partial event updates
This commit is contained in:
1 parent
76f0420d64
commit
db4acbb46e
14 files changed
+613
-16
No files matched your search
@@ -0,0 +1,101 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
existing: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => ({
|
||||||
|
...(await import("@/db/schema")),
|
||||||
|
db: {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({ where: () => ({ limit: mocks.existing }) }),
|
||||||
|
}),
|
||||||
|
insert: () => ({ values: mocks.insert }),
|
||||||
|
update: () => ({ set: () => ({ where: mocks.update }) }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction:
|
||||||
|
(
|
||||||
|
options: { schema: { parse: (data: unknown) => unknown } },
|
||||||
|
handler: (ctx: unknown) => unknown,
|
||||||
|
) =>
|
||||||
|
(data: unknown) =>
|
||||||
|
handler({
|
||||||
|
data: options.schema.parse(data),
|
||||||
|
session: { user: { id: 7, username: "Staff" } },
|
||||||
|
}),
|
||||||
|
authAction: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/foundation/action", () => ({ handleActionError: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs"));
|
||||||
|
|
||||||
|
import { createEvent, updateEvent } from "./events";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
title: "Event",
|
||||||
|
description: "Details",
|
||||||
|
typeId: 1,
|
||||||
|
startsAt: new Date("2030-01-01"),
|
||||||
|
status: "published" as const,
|
||||||
|
isRecurring: 0,
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.existing.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: "published",
|
||||||
|
title: "Event",
|
||||||
|
image: "/cover.png",
|
||||||
|
startsAt: new Date("2030-01-01"),
|
||||||
|
endsAt: new Date("2030-01-02"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mocks.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
mocks.update.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
it("rejects invalid published event images before writing", async () => {
|
||||||
|
await expect(
|
||||||
|
createEvent({ ...base, image: "javascript:alert(1)" }),
|
||||||
|
).rejects.toThrow("Check the event image");
|
||||||
|
expect(mocks.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("validates an update against existing dates before writing", async () => {
|
||||||
|
await expect(
|
||||||
|
updateEvent({ id: 1, endsAt: new Date("2029-12-01") }),
|
||||||
|
).rejects.toThrow("Check the event image");
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("allows draft saves and partial updates with unchanged valid dates", async () => {
|
||||||
|
await createEvent({ ...base, status: "draft", image: "javascript:alert(1)" });
|
||||||
|
expect(mocks.insert).toHaveBeenCalledOnce();
|
||||||
|
await updateEvent({ id: 1, title: "Changed", startsAt: undefined });
|
||||||
|
expect(mocks.update).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bypass published preflight when a partial update omits status", async () => {
|
||||||
|
await expect(
|
||||||
|
updateEvent({ id: 1, image: "javascript:alert(1)" }),
|
||||||
|
).rejects.toThrow("Check the event image");
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicitly removing an invalid image while publishing the draft", async () => {
|
||||||
|
mocks.existing.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: "draft",
|
||||||
|
title: "Draft",
|
||||||
|
image: "javascript:alert(1)",
|
||||||
|
startsAt: new Date("2030-01-01"),
|
||||||
|
endsAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await updateEvent({ id: 1, status: "published", image: "" });
|
||||||
|
expect(mocks.update).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
WebsiteEventWinner,
|
WebsiteEventWinner,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { publicationIssues } from "@/lib/publication-preflight";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
@@ -108,6 +109,15 @@ export const createEvent = adminAction(
|
|||||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
if (
|
||||||
|
ctx.data.status === "published" &&
|
||||||
|
publicationIssues({ kind: "event", ...ctx.data }).some(
|
||||||
|
(issue) => issue.severity === "error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ActionError(
|
||||||
|
"Check the event image and schedule before publishing",
|
||||||
|
);
|
||||||
const [result] = await db.insert(WebsiteEvent).values({
|
const [result] = await db.insert(WebsiteEvent).values({
|
||||||
...ctx.data,
|
...ctx.data,
|
||||||
hostUserId: Number(ctx.session.user.id),
|
hostUserId: Number(ctx.session.user.id),
|
||||||
@@ -143,11 +153,27 @@ export const updateEvent = adminAction(
|
|||||||
id: WebsiteEvent.id,
|
id: WebsiteEvent.id,
|
||||||
title: WebsiteEvent.title,
|
title: WebsiteEvent.title,
|
||||||
status: WebsiteEvent.status,
|
status: WebsiteEvent.status,
|
||||||
|
image: WebsiteEvent.image,
|
||||||
|
startsAt: WebsiteEvent.startsAt,
|
||||||
|
endsAt: WebsiteEvent.endsAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteEvent)
|
.from(WebsiteEvent)
|
||||||
.where(eq(WebsiteEvent.id, id))
|
.where(eq(WebsiteEvent.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing) throw new ActionError("Event not found");
|
if (!existing) throw new ActionError("Event not found");
|
||||||
|
if (
|
||||||
|
(data.status ?? existing.status) === "published" &&
|
||||||
|
publicationIssues({
|
||||||
|
kind: "event",
|
||||||
|
image: data.image === undefined ? existing.image : data.image,
|
||||||
|
startsAt:
|
||||||
|
data.startsAt === undefined ? existing.startsAt : data.startsAt,
|
||||||
|
endsAt: data.endsAt === undefined ? existing.endsAt : data.endsAt,
|
||||||
|
}).some((issue) => issue.severity === "error")
|
||||||
|
)
|
||||||
|
throw new ActionError(
|
||||||
|
"Check the event image and schedule before publishing",
|
||||||
|
);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(WebsiteEvent)
|
.update(WebsiteEvent)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { createEvent, updateEvent } from "@/actions/events";
|
import { createEvent, updateEvent } from "@/actions/events";
|
||||||
|
import { ArticlePreview } from "@/components/admin/article-preview";
|
||||||
|
import { PublicationPreflight } from "@/components/admin/publication-preflight";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
@@ -17,6 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
import { publicationIssues } from "@/lib/publication-preflight";
|
||||||
import type {
|
import type {
|
||||||
CreateEventInput,
|
CreateEventInput,
|
||||||
UpdateEventInput,
|
UpdateEventInput,
|
||||||
@@ -49,6 +53,8 @@ interface EventFormValues {
|
|||||||
|
|
||||||
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||||
const t = useTranslations("pages.admin.events");
|
const t = useTranslations("pages.admin.events");
|
||||||
|
const tAction = useTranslations("pages.admin.actions");
|
||||||
|
const [preview, setPreview] = useState(false);
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const isEditing = !!defaultValues?.id;
|
const isEditing = !!defaultValues?.id;
|
||||||
|
|
||||||
@@ -63,7 +69,16 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const values = form.watch();
|
||||||
|
const issues = publicationIssues({ kind: "event", ...values });
|
||||||
function onSubmit(data: EventFormValues) {
|
function onSubmit(data: EventFormValues) {
|
||||||
|
if (
|
||||||
|
data.status === "published" &&
|
||||||
|
publicationIssues({ kind: "event", ...data }).some(
|
||||||
|
(issue) => issue.severity === "error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (isEditing && defaultValues?.id) {
|
if (isEditing && defaultValues?.id) {
|
||||||
run(
|
run(
|
||||||
() =>
|
() =>
|
||||||
@@ -152,7 +167,7 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={form.watch("endsAt")}
|
value={form.watch("endsAt")}
|
||||||
onChange={(d) =>
|
onChange={(d) =>
|
||||||
form.setValue("endsAt", d, { shouldDirty: true })
|
form.setValue("endsAt", d ?? null, { shouldDirty: true })
|
||||||
}
|
}
|
||||||
placeholder={t("formSelectEnd")}
|
placeholder={t("formSelectEnd")}
|
||||||
/>
|
/>
|
||||||
@@ -214,8 +229,43 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<PublicationPreflight
|
||||||
<Button type="submit" disabled={isPending}>
|
issues={issues}
|
||||||
|
paths={[
|
||||||
|
"/events",
|
||||||
|
...(defaultValues?.id ? [`/events/${defaultValues.id}`] : []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ArticlePreview
|
||||||
|
title={tAction("preview")}
|
||||||
|
data={
|
||||||
|
preview
|
||||||
|
? {
|
||||||
|
title: values.title,
|
||||||
|
image: values.image ?? "",
|
||||||
|
summary: values.description,
|
||||||
|
body: "",
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onClose={() => setPreview(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPreview(true)}
|
||||||
|
>
|
||||||
|
{tAction("preview")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
(values.status === "published" &&
|
||||||
|
issues.some((issue) => issue.severity === "error"))
|
||||||
|
}
|
||||||
|
>
|
||||||
{isPending
|
{isPending
|
||||||
? t("formSaving")
|
? t("formSaving")
|
||||||
: isEditing
|
: isEditing
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
|||||||
import type { ArticleDraft } from "@/lib/article-draft";
|
import type { ArticleDraft } from "@/lib/article-draft";
|
||||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
|
import { publicationIssues } from "@/lib/publication-preflight";
|
||||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||||
import { useArticleRecovery } from "./article-recovery";
|
import { useArticleRecovery } from "./article-recovery";
|
||||||
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
||||||
import { MediaPicker } from "./media-picker";
|
import { MediaPicker } from "./media-picker";
|
||||||
|
import { PublicationPreflight } from "./publication-preflight";
|
||||||
import { RichText } from "./rich-text";
|
import { RichText } from "./rich-text";
|
||||||
|
|
||||||
export function ArticleForm({
|
export function ArticleForm({
|
||||||
@@ -91,14 +93,33 @@ export function ArticleForm({
|
|||||||
useFormErrorFocus(formRef, fieldErrors, pending);
|
useFormErrorFocus(formRef, fieldErrors, pending);
|
||||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||||
|
const effectiveSlug = slugify(slugTouched ? slug : suggestedSlug);
|
||||||
|
const issues = publicationIssues({
|
||||||
|
kind: "article",
|
||||||
|
image,
|
||||||
|
body,
|
||||||
|
slug: slugTouched ? slug : suggestedSlug,
|
||||||
|
normalizedSlug: effectiveSlug,
|
||||||
|
status,
|
||||||
|
publishAt,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
onChangeCapture={markDirty}
|
onChangeCapture={() => {
|
||||||
|
markDirty();
|
||||||
|
if (formRef.current)
|
||||||
|
setBody(String(new FormData(formRef.current).get("fullStory") ?? ""));
|
||||||
|
}}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current) return;
|
if (
|
||||||
|
saving.current ||
|
||||||
|
(status !== "draft" &&
|
||||||
|
issues.some((issue) => issue.severity === "error"))
|
||||||
|
)
|
||||||
|
return;
|
||||||
const data = new FormData(event.currentTarget);
|
const data = new FormData(event.currentTarget);
|
||||||
if (status === "scheduled" && publishAt) {
|
if (status === "scheduled" && publishAt) {
|
||||||
const date = new Date(publishAt);
|
const date = new Date(publishAt);
|
||||||
@@ -262,7 +283,13 @@ export function ArticleForm({
|
|||||||
</span>
|
</span>
|
||||||
<RichText
|
<RichText
|
||||||
key={bodyVersion}
|
key={bodyVersion}
|
||||||
onChange={markDirty}
|
onChange={() => {
|
||||||
|
markDirty();
|
||||||
|
if (formRef.current)
|
||||||
|
setBody(
|
||||||
|
String(new FormData(formRef.current).get("fullStory") ?? ""),
|
||||||
|
);
|
||||||
|
}}
|
||||||
name="fullStory"
|
name="fullStory"
|
||||||
defaultValue={body}
|
defaultValue={body}
|
||||||
placeholder={t("bodyPlaceholder")}
|
placeholder={t("bodyPlaceholder")}
|
||||||
@@ -314,6 +341,10 @@ export function ArticleForm({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PublicationPreflight
|
||||||
|
issues={issues}
|
||||||
|
paths={["/news", `/news/${effectiveSlug}`, "/"]}
|
||||||
|
/>
|
||||||
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -334,7 +365,11 @@ export function ArticleForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={pending}
|
disabled={
|
||||||
|
pending ||
|
||||||
|
(status !== "draft" &&
|
||||||
|
issues.some((issue) => issue.severity === "error"))
|
||||||
|
}
|
||||||
aria-busy={pending}
|
aria-busy={pending}
|
||||||
>
|
>
|
||||||
{pending ? tAction("saving") : t("save")}
|
{pending ? tAction("saving") : t("save")}
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export interface ArticlePreviewData {
|
|||||||
export function ArticlePreview({
|
export function ArticlePreview({
|
||||||
data,
|
data,
|
||||||
onClose,
|
onClose,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
data: ArticlePreviewData | null;
|
data: ArticlePreviewData | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.articles.form");
|
const t = useTranslations("pages.admin.articles.form");
|
||||||
const document = data
|
const document = data
|
||||||
@@ -42,13 +44,13 @@ export function ArticlePreview({
|
|||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("previewTitle")}</DialogTitle>
|
<DialogTitle>{title ?? t("previewTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("previewDescription")}
|
{t("previewDescription")}
|
||||||
</p>
|
</p>
|
||||||
<iframe
|
<iframe
|
||||||
title={t("previewFrame")}
|
title={title ?? t("previewFrame")}
|
||||||
sandbox=""
|
sandbox=""
|
||||||
srcDoc={document}
|
srcDoc={document}
|
||||||
className="min-h-0 flex-1 w-full bg-background rounded border"
|
className="min-h-0 flex-1 w-full bg-background rounded border"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "@/components/link";
|
||||||
|
import type { PublicationIssue } from "@/lib/publication-preflight";
|
||||||
|
export function PublicationPreflight({
|
||||||
|
issues,
|
||||||
|
paths,
|
||||||
|
}: {
|
||||||
|
issues: PublicationIssue[];
|
||||||
|
paths: string[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.publicationPreflight");
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-lg border border-[var(--admin-border)] p-4 space-y-2"
|
||||||
|
aria-label={t("title")}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">{t("title")}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
||||||
|
{issues.length ? (
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<li
|
||||||
|
key={issue.code}
|
||||||
|
className={
|
||||||
|
issue.severity === "error"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(issue.code)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">{t("ready")}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-medium">{t("affectedPages")}</p>
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
{paths.map((path) => (
|
||||||
|
<Link
|
||||||
|
key={path}
|
||||||
|
href={path}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline break-all"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("previewHint")}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { publicationIssues } from "./publication-preflight";
|
||||||
import type { ActionResult } from "./safe-action-shared";
|
import type { ActionResult } from "./safe-action-shared";
|
||||||
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||||
export class ArticleInputError extends Error {
|
export class ArticleInputError extends Error {
|
||||||
@@ -10,7 +11,9 @@ export class ArticleInputError extends Error {
|
|||||||
| "summaryTooLong"
|
| "summaryTooLong"
|
||||||
| "imageTooLong"
|
| "imageTooLong"
|
||||||
| "invalidStatus"
|
| "invalidStatus"
|
||||||
| "publishDateRequired",
|
| "publishDateRequired"
|
||||||
|
| "imageInvalid"
|
||||||
|
| "linksInvalid",
|
||||||
) {
|
) {
|
||||||
super(code);
|
super(code);
|
||||||
}
|
}
|
||||||
@@ -34,6 +37,17 @@ export function readArticleInput(form: FormData) {
|
|||||||
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||||
if (publishAt && Number.isNaN(publishAt.getTime()))
|
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||||
throw new ArticleInputError("publishDateRequired");
|
throw new ArticleInputError("publishDateRequired");
|
||||||
|
if (status !== "draft") {
|
||||||
|
const issue = publicationIssues({
|
||||||
|
kind: "article",
|
||||||
|
image,
|
||||||
|
body: fullStory,
|
||||||
|
status,
|
||||||
|
publishAt,
|
||||||
|
}).find((issue) => issue.severity === "error");
|
||||||
|
if (issue?.code === "imageInvalid" || issue?.code === "linksInvalid")
|
||||||
|
throw new ArticleInputError(issue.code);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
shortStory,
|
shortStory,
|
||||||
@@ -57,6 +71,8 @@ export function articleInputField(
|
|||||||
titleTooLong: "title",
|
titleTooLong: "title",
|
||||||
summaryTooLong: "shortStory",
|
summaryTooLong: "shortStory",
|
||||||
imageTooLong: "image",
|
imageTooLong: "image",
|
||||||
|
imageInvalid: "image",
|
||||||
|
linksInvalid: "fullStory",
|
||||||
invalidStatus: "status",
|
invalidStatus: "status",
|
||||||
publishDateRequired: "publishAt",
|
publishDateRequired: "publishAt",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readArticleInput } from "./article-input";
|
||||||
|
import { publicationIssues, safePublicationUrl } from "./publication-preflight";
|
||||||
|
|
||||||
|
describe("publication preflight", () => {
|
||||||
|
it("allows local images and web links but rejects unsafe schemes and credentials", () => {
|
||||||
|
expect(safePublicationUrl("/images/news.png", true)).toBe(true);
|
||||||
|
expect(safePublicationUrl("https://example.com/image.png", true)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const url of [
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"//external.test/image",
|
||||||
|
"https://user:[email protected]/a",
|
||||||
|
"data:text/html,test",
|
||||||
|
"https:\\example.com",
|
||||||
|
]) {
|
||||||
|
expect(safePublicationUrl(url, true)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("checks encoded link schemes without fetching destinations", () => {
|
||||||
|
const issues = publicationIssues({
|
||||||
|
kind: "article",
|
||||||
|
image: "/cover.png",
|
||||||
|
body: '<a href="javascript:alert(1)">bad</a>',
|
||||||
|
});
|
||||||
|
expect(issues).toContainEqual({
|
||||||
|
code: "linksInvalid",
|
||||||
|
severity: "error",
|
||||||
|
field: "fullStory",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
publicationIssues({
|
||||||
|
kind: "article",
|
||||||
|
image: "/cover.png",
|
||||||
|
body: '<a href="/help">Help</a><a href="mailto:[email protected]">Mail</a>',
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
it("rejects reversed event dates and distinguishes a past-date warning", () => {
|
||||||
|
expect(
|
||||||
|
publicationIssues({
|
||||||
|
kind: "event",
|
||||||
|
startsAt: "2030-01-02",
|
||||||
|
endsAt: "2030-01-01",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "scheduleInvalid", severity: "error" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
publicationIssues(
|
||||||
|
{ kind: "event", startsAt: "2020-01-01" },
|
||||||
|
Date.parse("2021-01-01"),
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "schedulePast", severity: "warning" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("warns for missing image and normalized slug without blocking drafts", () => {
|
||||||
|
const issues = publicationIssues({
|
||||||
|
kind: "article",
|
||||||
|
slug: "Hello World",
|
||||||
|
normalizedSlug: "hello-world",
|
||||||
|
});
|
||||||
|
expect(issues.map((i) => i.code)).toEqual([
|
||||||
|
"imageMissing",
|
||||||
|
"slugNormalized",
|
||||||
|
]);
|
||||||
|
expect(issues.every((i) => i.severity === "warning")).toBe(true);
|
||||||
|
});
|
||||||
|
it("requires a valid scheduled publication date", () => {
|
||||||
|
expect(
|
||||||
|
publicationIssues({
|
||||||
|
kind: "article",
|
||||||
|
status: "scheduled",
|
||||||
|
publishAt: "",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: "scheduleInvalid" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("enforces image and link checks in server article input but preserves draft saving", () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("title", "News");
|
||||||
|
form.set("image", "javascript:alert(1)");
|
||||||
|
expect(() => readArticleInput(form)).toThrow("imageInvalid");
|
||||||
|
form.set("status", "draft");
|
||||||
|
expect(readArticleInput(form).image).toBe("javascript:alert(1)");
|
||||||
|
form.set("status", "published");
|
||||||
|
form.set("image", "/cover.png");
|
||||||
|
form.set("fullStory", '<a href="javascript:alert(1)">bad</a>');
|
||||||
|
expect(() => readArticleInput(form)).toThrow("linksInvalid");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
export type PublicationIssueCode =
|
||||||
|
| "imageMissing"
|
||||||
|
| "imageInvalid"
|
||||||
|
| "linksInvalid"
|
||||||
|
| "scheduleInvalid"
|
||||||
|
| "schedulePast"
|
||||||
|
| "slugNormalized";
|
||||||
|
export interface PublicationIssue {
|
||||||
|
code: PublicationIssueCode;
|
||||||
|
severity: "error" | "warning";
|
||||||
|
field: string;
|
||||||
|
}
|
||||||
|
export interface PublicationInput {
|
||||||
|
kind: "article" | "event";
|
||||||
|
image?: string | null;
|
||||||
|
body?: string;
|
||||||
|
slug?: string;
|
||||||
|
normalizedSlug?: string;
|
||||||
|
status?: string;
|
||||||
|
publishAt?: Date | string | null;
|
||||||
|
startsAt?: Date | string | null;
|
||||||
|
endsAt?: Date | string | null;
|
||||||
|
}
|
||||||
|
export function safePublicationUrl(value: string, image = false): boolean {
|
||||||
|
const text = value.trim();
|
||||||
|
if (
|
||||||
|
!text ||
|
||||||
|
[...text].some((char) => char.charCodeAt(0) <= 32 || char === "\\")
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (text.startsWith("/") && !text.startsWith("//")) return true;
|
||||||
|
if (!image && text.startsWith("#")) return true;
|
||||||
|
try {
|
||||||
|
const url = new URL(text);
|
||||||
|
return (
|
||||||
|
!url.username &&
|
||||||
|
!url.password &&
|
||||||
|
(url.protocol === "https:" ||
|
||||||
|
url.protocol === "http:" ||
|
||||||
|
(!image && url.protocol === "mailto:"))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function decodeLink(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&#(x[\da-f]+|\d+);?/gi, (_all, code: string) => {
|
||||||
|
const n =
|
||||||
|
code[0].toLowerCase() === "x"
|
||||||
|
? Number.parseInt(code.slice(1), 16)
|
||||||
|
: Number(code);
|
||||||
|
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
|
||||||
|
})
|
||||||
|
.replace(/:/gi, ":")
|
||||||
|
.replace(/&/gi, "&");
|
||||||
|
}
|
||||||
|
export function publicationIssues(
|
||||||
|
input: PublicationInput,
|
||||||
|
now = Date.now(),
|
||||||
|
): PublicationIssue[] {
|
||||||
|
const issues: PublicationIssue[] = [];
|
||||||
|
if (!input.image?.trim())
|
||||||
|
issues.push({ code: "imageMissing", severity: "warning", field: "image" });
|
||||||
|
else if (!safePublicationUrl(input.image, true))
|
||||||
|
issues.push({ code: "imageInvalid", severity: "error", field: "image" });
|
||||||
|
if (input.kind === "article") {
|
||||||
|
const links = [
|
||||||
|
...(input.body ?? "").matchAll(
|
||||||
|
/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
|
||||||
|
),
|
||||||
|
].map((match) => decodeLink(match[1] ?? match[2] ?? match[3] ?? ""));
|
||||||
|
if (links.some((link) => !safePublicationUrl(link)))
|
||||||
|
issues.push({
|
||||||
|
code: "linksInvalid",
|
||||||
|
severity: "error",
|
||||||
|
field: "fullStory",
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
input.slug &&
|
||||||
|
input.normalizedSlug &&
|
||||||
|
input.slug !== input.normalizedSlug
|
||||||
|
)
|
||||||
|
issues.push({
|
||||||
|
code: "slugNormalized",
|
||||||
|
severity: "warning",
|
||||||
|
field: "slug",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const date =
|
||||||
|
input.kind === "event"
|
||||||
|
? input.startsAt
|
||||||
|
: input.status === "scheduled"
|
||||||
|
? input.publishAt
|
||||||
|
: null;
|
||||||
|
if (input.kind === "event" || input.status === "scheduled") {
|
||||||
|
const start = date ? new Date(date).getTime() : NaN;
|
||||||
|
const end = input.endsAt ? new Date(input.endsAt).getTime() : null;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(start) ||
|
||||||
|
(end !== null && (!Number.isFinite(end) || end <= start))
|
||||||
|
)
|
||||||
|
issues.push({
|
||||||
|
code: "scheduleInvalid",
|
||||||
|
severity: "error",
|
||||||
|
field: input.kind === "event" ? "startsAt" : "publishAt",
|
||||||
|
});
|
||||||
|
else if (start < now)
|
||||||
|
issues.push({
|
||||||
|
code: "schedulePast",
|
||||||
|
severity: "warning",
|
||||||
|
field: input.kind === "event" ? "startsAt" : "publishAt",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { createEventSchema, eventTypeSchema } from "./event";
|
import { createEventSchema, eventTypeSchema, updateEventSchema } from "./event";
|
||||||
|
|
||||||
describe("eventTypeSchema", () => {
|
describe("eventTypeSchema", () => {
|
||||||
it("accepts valid type", () => {
|
it("accepts valid type", () => {
|
||||||
@@ -98,3 +98,27 @@ describe("createEventSchema", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves omitted status and recurrence on partial event updates", () => {
|
||||||
|
expect(updateEventSchema.parse({ title: "Changed" })).toEqual({
|
||||||
|
title: "Changed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("rejects a null event start instead of converting it to the Unix epoch", () => {
|
||||||
|
expect(
|
||||||
|
createEventSchema.safeParse({
|
||||||
|
title: "Event",
|
||||||
|
description: "Details",
|
||||||
|
typeId: 1,
|
||||||
|
startsAt: null,
|
||||||
|
status: "published",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes an omitted image from explicit image removal", () => {
|
||||||
|
expect(updateEventSchema.parse({ title: "Changed" })).not.toHaveProperty(
|
||||||
|
"image",
|
||||||
|
);
|
||||||
|
expect(updateEventSchema.parse({ image: "" })).toEqual({ image: null });
|
||||||
|
});
|
||||||
@@ -25,7 +25,11 @@ export const createEventSchema = z.object({
|
|||||||
emptyToUndefined,
|
emptyToUndefined,
|
||||||
z.coerce.number().int().positive().nullable().optional(),
|
z.coerce.number().int().positive().nullable().optional(),
|
||||||
),
|
),
|
||||||
startsAt: z.coerce.date(),
|
startsAt: z.preprocess(
|
||||||
|
(value) =>
|
||||||
|
value == null || typeof value === "boolean" ? undefined : value,
|
||||||
|
z.coerce.date(),
|
||||||
|
),
|
||||||
endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()),
|
endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()),
|
||||||
maxPlayers: z.preprocess(
|
maxPlayers: z.preprocess(
|
||||||
emptyToUndefined,
|
emptyToUndefined,
|
||||||
@@ -45,7 +49,15 @@ export const createEventSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateEventSchema = createEventSchema.partial();
|
// Zod applies defaults inside optional schemas; partial edits must not reset state.
|
||||||
|
export const updateEventSchema = createEventSchema.partial().extend({
|
||||||
|
status: createEventSchema.shape.status.removeDefault().optional(),
|
||||||
|
isRecurring: createEventSchema.shape.isRecurring.removeDefault().optional(),
|
||||||
|
image: z.preprocess(
|
||||||
|
(value) => (value === "" ? null : value),
|
||||||
|
z.string().max(500).nullable().optional(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const eventPrizeSchema = z.object({
|
export const eventPrizeSchema = z.object({
|
||||||
eventId: z.coerce.number().int().positive(),
|
eventId: z.coerce.number().int().positive(),
|
||||||
|
|||||||
+21
-1
@@ -1237,7 +1237,9 @@
|
|||||||
"restoreRevision": "Restore into editor",
|
"restoreRevision": "Restore into editor",
|
||||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||||
"retryRecovery": "Reload recovery without changing this form"
|
"retryRecovery": "Reload recovery without changing this form",
|
||||||
|
"imageInvalid": "Use a local image path or an HTTP/HTTPS image URL without credentials.",
|
||||||
|
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed."
|
||||||
},
|
},
|
||||||
"articlesShown": "Articles shown",
|
"articlesShown": "Articles shown",
|
||||||
"latest": "Latest article",
|
"latest": "Latest article",
|
||||||
@@ -4332,6 +4334,19 @@
|
|||||||
"start": "Start synchronization",
|
"start": "Start synchronization",
|
||||||
"description": "Synchronization runs in the background. Progress and safe retries remain in your import history after you leave this page.",
|
"description": "Synchronization runs in the background. Progress and safe retries remain in your import history after you leave this page.",
|
||||||
"failed": "Could not queue synchronization"
|
"failed": "Could not queue synchronization"
|
||||||
|
},
|
||||||
|
"publicationPreflight": {
|
||||||
|
"affectedPages": "Affected public pages",
|
||||||
|
"scheduleInvalid": "Choose a valid start or publication date. An event end must follow its start.",
|
||||||
|
"description": "Checks use the current form values. Remote image and link availability is not tested.",
|
||||||
|
"slugNormalized": "The public address will use the normalized slug shown below.",
|
||||||
|
"imageInvalid": "Use a local image path or an HTTP/HTTPS image URL without credentials.",
|
||||||
|
"imageMissing": "No cover image selected. The page will use its fallback.",
|
||||||
|
"title": "Publication checks",
|
||||||
|
"ready": "Image, links and schedule passed the local checks.",
|
||||||
|
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
||||||
|
"schedulePast": "The selected date is in the past. Review it before publishing.",
|
||||||
|
"previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mod": {
|
"mod": {
|
||||||
@@ -4674,5 +4689,10 @@
|
|||||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publicData": {
|
||||||
|
"loadError": "This section could not be loaded. Please try again.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"unavailable": "Unavailable"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-1
@@ -1211,7 +1211,9 @@
|
|||||||
"restoreRevision": "Ripristina nell’editor",
|
"restoreRevision": "Ripristina nell’editor",
|
||||||
"confirmRevision": "Sostituire il contenuto corrente con questa versione precedente come bozza? Controllala e scegli lo stato di pubblicazione prima di salvare.",
|
"confirmRevision": "Sostituire il contenuto corrente con questa versione precedente come bozza? Controllala e scegli lo stato di pubblicazione prima di salvare.",
|
||||||
"editConflict": "Questo articolo è stato modificato dopo la tua apertura. Le tue modifiche non sono state applicate. Copia il lavoro, ricarica l’articolo e integra le modifiche prima di salvare di nuovo.",
|
"editConflict": "Questo articolo è stato modificato dopo la tua apertura. Le tue modifiche non sono state applicate. Copia il lavoro, ricarica l’articolo e integra le modifiche prima di salvare di nuovo.",
|
||||||
"retryRecovery": "Ricarica il recupero senza modificare il modulo"
|
"retryRecovery": "Ricarica il recupero senza modificare il modulo",
|
||||||
|
"imageInvalid": "Usa un percorso locale o un URL immagine HTTP/HTTPS senza credenziali.",
|
||||||
|
"linksInvalid": "Controlla i link del contenuto: sono ammessi link locali, HTTP/HTTPS ed email."
|
||||||
},
|
},
|
||||||
"articlesShown": "Articoli mostrati",
|
"articlesShown": "Articoli mostrati",
|
||||||
"latest": "Ultimo articolo",
|
"latest": "Ultimo articolo",
|
||||||
@@ -4307,6 +4309,19 @@
|
|||||||
"start": "Avvia sincronizzazione",
|
"start": "Avvia sincronizzazione",
|
||||||
"description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.",
|
"description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.",
|
||||||
"failed": "Impossibile accodare la sincronizzazione"
|
"failed": "Impossibile accodare la sincronizzazione"
|
||||||
|
},
|
||||||
|
"publicationPreflight": {
|
||||||
|
"affectedPages": "Pagine pubbliche interessate",
|
||||||
|
"scheduleInvalid": "Scegli una data valida. La fine di un evento deve seguire il suo inizio.",
|
||||||
|
"description": "I controlli usano i valori del modulo. La disponibilita remota di immagini e link non viene verificata.",
|
||||||
|
"slugNormalized": "Il percorso pubblico usera lo slug normalizzato indicato sotto.",
|
||||||
|
"imageInvalid": "Usa un percorso locale o un URL immagine HTTP/HTTPS senza credenziali.",
|
||||||
|
"imageMissing": "Nessuna copertina selezionata. La pagina usera la propria immagine alternativa.",
|
||||||
|
"title": "Controlli pubblicazione",
|
||||||
|
"ready": "Immagine, link e date hanno superato i controlli locali.",
|
||||||
|
"linksInvalid": "Controlla i link del contenuto: sono ammessi link locali, HTTP/HTTPS ed email.",
|
||||||
|
"schedulePast": "La data selezionata e nel passato. Controllala prima di pubblicare.",
|
||||||
|
"previewHint": "I link mostrano le pagine pubbliche attuali. Usa Anteprima per vedere il contenuto non salvato. Lo slug finale potrebbe ricevere un suffisso se gia utilizzato."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radioRequests": {
|
"radioRequests": {
|
||||||
@@ -4679,5 +4694,10 @@
|
|||||||
"excludedConflicts": "{count, plural, one {# elemento richiede un controllo ed è escluso da questa importazione.} other {# elementi richiedono un controllo e sono esclusi da questa importazione.}}"
|
"excludedConflicts": "{count, plural, one {# elemento richiede un controllo ed è escluso da questa importazione.} other {# elementi richiedono un controllo e sono esclusi da questa importazione.}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publicData": {
|
||||||
|
"loadError": "Non è stato possibile caricare questa sezione. Riprova.",
|
||||||
|
"retry": "Riprova",
|
||||||
|
"unavailable": "Non disponibile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-1
@@ -1222,7 +1222,9 @@
|
|||||||
"statusScheduled": "Ingepland",
|
"statusScheduled": "Ingepland",
|
||||||
"statusPublished": "Gepubliceerd",
|
"statusPublished": "Gepubliceerd",
|
||||||
"publishAt": "Publiceren op",
|
"publishAt": "Publiceren op",
|
||||||
"publishedAt": "Gepubliceerd op"
|
"publishedAt": "Gepubliceerd op",
|
||||||
|
"imageInvalid": "Gebruik een lokaal pad of een HTTP/HTTPS-afbeeldingsadres zonder inloggegevens.",
|
||||||
|
"linksInvalid": "Controleer de links: alleen lokale, HTTP/HTTPS- en e-maillinks zijn toegestaan."
|
||||||
},
|
},
|
||||||
"listActionError": "De actie voor dit artikel kon niet worden voltooid.",
|
"listActionError": "De actie voor dit artikel kon niet worden voltooid.",
|
||||||
"listStatus": "Publicatiestatus",
|
"listStatus": "Publicatiestatus",
|
||||||
@@ -3923,6 +3925,19 @@
|
|||||||
"start": "Synchronisatie starten",
|
"start": "Synchronisatie starten",
|
||||||
"description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.",
|
"description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.",
|
||||||
"failed": "Synchronisatie kon niet worden ingepland"
|
"failed": "Synchronisatie kon niet worden ingepland"
|
||||||
|
},
|
||||||
|
"publicationPreflight": {
|
||||||
|
"affectedPages": "Betrokken openbare paginas",
|
||||||
|
"scheduleInvalid": "Kies een geldige datum. De eindtijd van een evenement moet na de begintijd liggen.",
|
||||||
|
"description": "Controles gebruiken de huidige formulierwaarden. Externe afbeeldingen en links worden niet op beschikbaarheid getest.",
|
||||||
|
"slugNormalized": "Het openbare adres gebruikt de hieronder getoonde genormaliseerde slug.",
|
||||||
|
"imageInvalid": "Gebruik een lokaal pad of een HTTP/HTTPS-afbeeldingsadres zonder inloggegevens.",
|
||||||
|
"imageMissing": "Geen omslagafbeelding geselecteerd. De pagina gebruikt de standaardafbeelding.",
|
||||||
|
"title": "Publicatiecontroles",
|
||||||
|
"ready": "Afbeelding, links en planning voldoen aan de lokale controles.",
|
||||||
|
"linksInvalid": "Controleer de links: alleen lokale, HTTP/HTTPS- en e-maillinks zijn toegestaan.",
|
||||||
|
"schedulePast": "De geselecteerde datum ligt in het verleden. Controleer deze voor publicatie.",
|
||||||
|
"previewHint": "Deze links tonen de huidige openbare paginas. Gebruik Voorbeeld voor niet-opgeslagen inhoud. De definitieve artikelslug kan een achtervoegsel krijgen als deze al bestaat."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mod": {
|
"mod": {
|
||||||
@@ -4150,5 +4165,10 @@
|
|||||||
"excludedConflicts": "{count, plural, one {# item moet worden gecontroleerd en is uitgesloten van deze import.} other {# items moeten worden gecontroleerd en zijn uitgesloten van deze import.}}"
|
"excludedConflicts": "{count, plural, one {# item moet worden gecontroleerd en is uitgesloten van deze import.} other {# items moeten worden gecontroleerd en zijn uitgesloten van deze import.}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"publicData": {
|
||||||
|
"loadError": "Dit onderdeel kon niet worden geladen. Probeer het opnieuw.",
|
||||||
|
"retry": "Opnieuw proberen",
|
||||||
|
"unavailable": "Niet beschikbaar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user