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
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ import {
|
||||
WebsiteEventWinner,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { publicationIssues } from "@/lib/publication-preflight";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
@@ -108,6 +109,15 @@ export const createEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||
async (ctx) => {
|
||||
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({
|
||||
...ctx.data,
|
||||
hostUserId: Number(ctx.session.user.id),
|
||||
@@ -143,11 +153,27 @@ export const updateEvent = adminAction(
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
image: WebsiteEvent.image,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.where(eq(WebsiteEvent.id, id))
|
||||
.limit(1);
|
||||
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
|
||||
.update(WebsiteEvent)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { publicationIssues } from "@/lib/publication-preflight";
|
||||
import type {
|
||||
CreateEventInput,
|
||||
UpdateEventInput,
|
||||
@@ -49,6 +53,8 @@ interface EventFormValues {
|
||||
|
||||
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
const t = useTranslations("pages.admin.events");
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const { run, isPending } = useServerAction();
|
||||
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) {
|
||||
if (
|
||||
data.status === "published" &&
|
||||
publicationIssues({ kind: "event", ...data }).some(
|
||||
(issue) => issue.severity === "error",
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (isEditing && defaultValues?.id) {
|
||||
run(
|
||||
() =>
|
||||
@@ -152,7 +167,7 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
<DateTimePicker
|
||||
value={form.watch("endsAt")}
|
||||
onChange={(d) =>
|
||||
form.setValue("endsAt", d, { shouldDirty: true })
|
||||
form.setValue("endsAt", d ?? null, { shouldDirty: true })
|
||||
}
|
||||
placeholder={t("formSelectEnd")}
|
||||
/>
|
||||
@@ -214,8 +229,43 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<PublicationPreflight
|
||||
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
|
||||
? t("formSaving")
|
||||
: isEditing
|
||||
|
||||
@@ -7,10 +7,12 @@ import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { publicationIssues } from "@/lib/publication-preflight";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { PublicationPreflight } from "./publication-preflight";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
export function ArticleForm({
|
||||
@@ -91,14 +93,33 @@ export function ArticleForm({
|
||||
useFormErrorFocus(formRef, fieldErrors, pending);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
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 (
|
||||
<form
|
||||
ref={formRef}
|
||||
onChangeCapture={markDirty}
|
||||
onChangeCapture={() => {
|
||||
markDirty();
|
||||
if (formRef.current)
|
||||
setBody(String(new FormData(formRef.current).get("fullStory") ?? ""));
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
if (
|
||||
saving.current ||
|
||||
(status !== "draft" &&
|
||||
issues.some((issue) => issue.severity === "error"))
|
||||
)
|
||||
return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
if (status === "scheduled" && publishAt) {
|
||||
const date = new Date(publishAt);
|
||||
@@ -262,7 +283,13 @@ export function ArticleForm({
|
||||
</span>
|
||||
<RichText
|
||||
key={bodyVersion}
|
||||
onChange={markDirty}
|
||||
onChange={() => {
|
||||
markDirty();
|
||||
if (formRef.current)
|
||||
setBody(
|
||||
String(new FormData(formRef.current).get("fullStory") ?? ""),
|
||||
);
|
||||
}}
|
||||
name="fullStory"
|
||||
defaultValue={body}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
@@ -314,6 +341,10 @@ export function ArticleForm({
|
||||
) : null}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -334,7 +365,11 @@ export function ArticleForm({
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={pending}
|
||||
disabled={
|
||||
pending ||
|
||||
(status !== "draft" &&
|
||||
issues.some((issue) => issue.severity === "error"))
|
||||
}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? tAction("saving") : t("save")}
|
||||
|
||||
@@ -25,9 +25,11 @@ export interface ArticlePreviewData {
|
||||
export function ArticlePreview({
|
||||
data,
|
||||
onClose,
|
||||
title,
|
||||
}: {
|
||||
data: ArticlePreviewData | null;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const document = data
|
||||
@@ -42,13 +44,13 @@ export function ArticlePreview({
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("previewTitle")}</DialogTitle>
|
||||
<DialogTitle>{title ?? t("previewTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("previewDescription")}
|
||||
</p>
|
||||
<iframe
|
||||
title={t("previewFrame")}
|
||||
title={title ?? t("previewFrame")}
|
||||
sandbox=""
|
||||
srcDoc={document}
|
||||
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";
|
||||
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
@@ -10,7 +11,9 @@ export class ArticleInputError extends Error {
|
||||
| "summaryTooLong"
|
||||
| "imageTooLong"
|
||||
| "invalidStatus"
|
||||
| "publishDateRequired",
|
||||
| "publishDateRequired"
|
||||
| "imageInvalid"
|
||||
| "linksInvalid",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
@@ -34,6 +37,17 @@ export function readArticleInput(form: FormData) {
|
||||
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||
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 {
|
||||
title,
|
||||
shortStory,
|
||||
@@ -57,6 +71,8 @@ export function articleInputField(
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
imageInvalid: "image",
|
||||
linksInvalid: "fullStory",
|
||||
invalidStatus: "status",
|
||||
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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEventSchema, eventTypeSchema } from "./event";
|
||||
import { createEventSchema, eventTypeSchema, updateEventSchema } from "./event";
|
||||
|
||||
describe("eventTypeSchema", () => {
|
||||
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,
|
||||
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()),
|
||||
maxPlayers: z.preprocess(
|
||||
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({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
|
||||
+21
-1
@@ -1237,7 +1237,9 @@
|
||||
"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.",
|
||||
"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",
|
||||
"latest": "Latest article",
|
||||
@@ -4332,6 +4334,19 @@
|
||||
"start": "Start synchronization",
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -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.}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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.",
|
||||
"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",
|
||||
"latest": "Ultimo articolo",
|
||||
@@ -4307,6 +4309,19 @@
|
||||
"start": "Avvia sincronizzazione",
|
||||
"description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.",
|
||||
"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": {
|
||||
@@ -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.}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicData": {
|
||||
"loadError": "Non è stato possibile caricare questa sezione. Riprova.",
|
||||
"retry": "Riprova",
|
||||
"unavailable": "Non disponibile"
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1222,7 +1222,9 @@
|
||||
"statusScheduled": "Ingepland",
|
||||
"statusPublished": "Gepubliceerd",
|
||||
"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.",
|
||||
"listStatus": "Publicatiestatus",
|
||||
@@ -3923,6 +3925,19 @@
|
||||
"start": "Synchronisatie starten",
|
||||
"description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.",
|
||||
"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": {
|
||||
@@ -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.}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicData": {
|
||||
"loadError": "Dit onderdeel kon niet worden geladen. Probeer het opnieuw.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"unavailable": "Niet beschikbaar"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user