fix(news): restore publication flow and deduplicate dashboard friends
This commit is contained in:
1 parent
ef94646d60
commit
9b0ea2fb16
22 files changed
+638
-195
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
-- Existing articles remain published. Preserve any publication settings already present.
|
||||||
|
ALTER TABLE website_articles
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published',
|
||||||
|
ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_website_articles_publication
|
||||||
|
ON website_articles (status, publish_at, created_at);
|
||||||
@@ -12,6 +12,7 @@ import { logger } from "../src/lib/logger";
|
|||||||
import { redis } from "../src/lib/redis";
|
import { redis } from "../src/lib/redis";
|
||||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
||||||
|
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
||||||
import { rcon } from "../src/lib/services/rcon";
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
|
|
||||||
function captureWorkerError(err: unknown, context: string): void {
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
@@ -246,12 +247,10 @@ async function publishScheduledArticles(): Promise<void> {
|
|||||||
lte(WebsiteArticles.publishAt, now),
|
lte(WebsiteArticles.publishAt, now),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const info = result as unknown as {
|
const [info] = result;
|
||||||
affectedRows?: number;
|
const published = info.affectedRows ?? 0;
|
||||||
rowsAffected?: number;
|
|
||||||
};
|
|
||||||
const published = info.affectedRows ?? info.rowsAffected ?? 0;
|
|
||||||
if (published > 0) {
|
if (published > 0) {
|
||||||
|
await invalidateNewsCache();
|
||||||
logger.info(`Published ${published} scheduled article(s)`, {
|
logger.info(`Published ${published} scheduled article(s)`, {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
rows: [] as unknown[][],
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
log: vi.fn(() => "news-error-1"),
|
||||||
|
notify: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
|
||||||
|
vi.mock("@/lib/services/news-cache", () => ({
|
||||||
|
invalidateNewsCache: state.invalidate,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getTranslations: async () => (key: string, args?: { reference: string }) =>
|
||||||
|
key + (args?.reference ?? ""),
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (url: string) => {
|
||||||
|
throw Error(`redirect ${url}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => ({
|
||||||
|
...(await import("@/db/schema")),
|
||||||
|
db: {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({ values: state.insert }),
|
||||||
|
update: () => ({
|
||||||
|
set: (value: unknown) => {
|
||||||
|
state.update(value);
|
||||||
|
return { where: async () => [{ affectedRows: 1 }] };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createArticle, updateArticle } from "./admin-articles";
|
||||||
|
|
||||||
|
function form(extra: Record<string, string> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries({
|
||||||
|
title: "Test news",
|
||||||
|
slug: "test-news",
|
||||||
|
shortStory: "Summary",
|
||||||
|
fullStory: "Body",
|
||||||
|
status: "published",
|
||||||
|
...extra,
|
||||||
|
}))
|
||||||
|
f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
describe("news saves", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.rows = [];
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
});
|
||||||
|
it("preserves a recoverable error and logs its reference", async () => {
|
||||||
|
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
|
||||||
|
const result = await createArticle(form());
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "saveFailedReferencenews-error-1",
|
||||||
|
});
|
||||||
|
expect(state.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(state.log).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("saves drafts without publication notifications", async () => {
|
||||||
|
expect((await createArticle(form({ status: "draft" }))).ok).toBe(true);
|
||||||
|
expect(state.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "draft",
|
||||||
|
publishAt: null,
|
||||||
|
publishedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.notify).not.toHaveBeenCalled();
|
||||||
|
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||||
|
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateArticle(
|
||||||
|
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "test-news",
|
||||||
|
publishAt: null,
|
||||||
|
status: "published",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it("rejects invalid scheduled dates before writing", async () => {
|
||||||
|
expect(
|
||||||
|
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+98
-103
@@ -1,9 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
ArticleInputError,
|
||||||
|
type ArticleSaveResult,
|
||||||
|
readArticleInput,
|
||||||
|
} from "@/lib/article-input";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
WebsiteArticleComments,
|
WebsiteArticleComments,
|
||||||
@@ -12,152 +18,139 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||||
import { notify } from "@/lib/services/webhook";
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
|
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||||
type ArticleStatus = (typeof ARTICLE_STATUSES)[number];
|
|
||||||
|
|
||||||
function parseArticleStatus(raw: unknown): ArticleStatus {
|
|
||||||
const value = String(raw ?? "published").trim();
|
|
||||||
return (ARTICLE_STATUSES as readonly string[]).includes(value)
|
|
||||||
? (value as ArticleStatus)
|
|
||||||
: "published";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePublishAt(raw: unknown): Date | null {
|
|
||||||
const value = String(raw ?? "").trim();
|
|
||||||
if (!value) return null;
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let n = 2;
|
for (let n = 2; ; n++) {
|
||||||
for (;;) {
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: WebsiteArticles.id })
|
.select({ id: WebsiteArticles.id })
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.slug, slug),
|
||||||
|
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing) return slug;
|
if (!existing) return slug;
|
||||||
slug = `${base}-${n++}`;
|
const suffix = `-${n}`;
|
||||||
|
slug = base.slice(0, 255 - suffix.length) + suffix;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function saveFailure(
|
||||||
export async function createArticle(formData: FormData): Promise<void> {
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
): Promise<ArticleSaveResult> {
|
||||||
|
const t = await getTranslations("pages.admin.articles.form");
|
||||||
|
if (error instanceof ArticleInputError)
|
||||||
|
return { ok: false, error: t(error.code) };
|
||||||
|
const reference = logger.error("News save failed", {
|
||||||
|
module: "news",
|
||||||
|
operation,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return { ok: false, error: t("saveFailedReference", { reference }) };
|
||||||
|
}
|
||||||
|
async function refreshNews() {
|
||||||
|
await invalidateNewsCache();
|
||||||
|
revalidatePath("/admin/articles");
|
||||||
|
revalidatePath("/news", "layout");
|
||||||
|
revalidatePath("/me");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
export async function createArticle(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ArticleSaveResult> {
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const title = String(formData.get("title") ?? "")
|
let input: ReturnType<typeof readArticleInput>;
|
||||||
.normalize("NFC")
|
let slug: string;
|
||||||
.trim();
|
|
||||||
const shortStory = String(formData.get("shortStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const fullStory = String(formData.get("fullStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const image = String(formData.get("image") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
|
||||||
const status = parseArticleStatus(formData.get("status"));
|
|
||||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
|
||||||
if (!title) return;
|
|
||||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
|
||||||
redirect(
|
|
||||||
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
input = readArticleInput(formData);
|
||||||
|
const { rawSlug, ...fields } = input;
|
||||||
|
slug = await uniqueSlug(rawSlug || fields.title);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const publishAt = parsePublishAt(rawPublishAt);
|
|
||||||
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
|
||||||
await db.insert(WebsiteArticles).values({
|
await db.insert(WebsiteArticles).values({
|
||||||
|
...fields,
|
||||||
slug,
|
slug,
|
||||||
title: title.slice(0, 255),
|
|
||||||
shortStory: shortStory.slice(0, 255),
|
|
||||||
fullStory,
|
|
||||||
image: image.slice(0, 255),
|
|
||||||
userId: staff.id,
|
userId: staff.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status,
|
publishedAt: fields.status === "published" ? now : null,
|
||||||
publishAt,
|
|
||||||
publishedAt: status === "published" ? now : null,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return saveFailure(error, "create");
|
||||||
|
}
|
||||||
|
// Auxiliary services must not turn a committed insert into an apparent failure.
|
||||||
|
if (input.status === "published")
|
||||||
notify({
|
notify({
|
||||||
action: "news_publish",
|
action: "news_publish",
|
||||||
actor: staff.username,
|
actor: staff.username,
|
||||||
target: title,
|
target: input.title,
|
||||||
details: slug,
|
details: slug,
|
||||||
});
|
});
|
||||||
} catch {
|
await refreshNews();
|
||||||
// Database error — re-render unchanged with error.
|
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||||
redirect(
|
|
||||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
redirect("/admin/articles");
|
|
||||||
}
|
}
|
||||||
|
export async function updateArticle(
|
||||||
export async function updateArticle(formData: FormData): Promise<void> {
|
formData: FormData,
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
): Promise<ArticleSaveResult> {
|
||||||
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
|
const t = await getTranslations("pages.admin.articles.form");
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) redirect("/admin/articles?error=Missing article id");
|
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
let input: ReturnType<typeof readArticleInput>;
|
||||||
const status = parseArticleStatus(formData.get("status"));
|
let becamePublished = false;
|
||||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
let slug: string;
|
||||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
|
||||||
redirect(
|
|
||||||
"/admin/articles?error=Scheduled articles need a valid publish date.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const publishAt = parsePublishAt(rawPublishAt);
|
input = readArticleInput(formData);
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({
|
.select({
|
||||||
|
slug: WebsiteArticles.slug,
|
||||||
status: WebsiteArticles.status,
|
status: WebsiteArticles.status,
|
||||||
publishedAt: WebsiteArticles.publishedAt,
|
publishedAt: WebsiteArticles.publishedAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.id, id))
|
.where(eq(WebsiteArticles.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const publishedAt =
|
if (!existing) return { ok: false, error: t("articleNotFound") };
|
||||||
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
|
const { rawSlug, ...fields } = input;
|
||||||
|
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||||
|
slug =
|
||||||
|
requestedSlug === existing.slug
|
||||||
|
? existing.slug
|
||||||
|
: await uniqueSlug(requestedSlug, id);
|
||||||
|
becamePublished =
|
||||||
|
fields.status === "published" && existing.status !== "published";
|
||||||
await db
|
await db
|
||||||
.update(WebsiteArticles)
|
.update(WebsiteArticles)
|
||||||
.set({
|
.set({
|
||||||
title: String(formData.get("title") ?? "")
|
...fields,
|
||||||
.normalize("NFC")
|
slug,
|
||||||
.trim()
|
publishedAt:
|
||||||
.slice(0, 255),
|
fields.status === "published"
|
||||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
? (existing.publishedAt ?? new Date())
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
: null,
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
fullStory: String(formData.get("fullStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim(),
|
|
||||||
image: String(formData.get("image") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
status,
|
|
||||||
publishAt,
|
|
||||||
publishedAt,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(WebsiteArticles.id, id));
|
.where(eq(WebsiteArticles.id, id));
|
||||||
} catch {
|
} catch (error) {
|
||||||
redirect("/admin/articles?error=Update failed");
|
return saveFailure(error, "update");
|
||||||
}
|
}
|
||||||
|
if (becamePublished)
|
||||||
|
notify({
|
||||||
|
action: "news_publish",
|
||||||
|
actor: staff.username,
|
||||||
|
target: input.title,
|
||||||
|
details: slug,
|
||||||
|
});
|
||||||
|
await refreshNews();
|
||||||
revalidatePath(`/admin/articles/${id}`);
|
revalidatePath(`/admin/articles/${id}`);
|
||||||
redirect("/admin/articles");
|
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
@@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
actor: staff.username,
|
actor: staff.username,
|
||||||
target: article?.title ?? String(id),
|
target: article?.title ?? String(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("News deletion failed", { module: "news", error });
|
||||||
redirect("/admin/articles?error=Delete failed");
|
redirect("/admin/articles?error=Delete failed");
|
||||||
}
|
}
|
||||||
|
await refreshNews();
|
||||||
redirect("/admin/articles");
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
@@ -99,13 +99,7 @@ export default async function MePage({
|
|||||||
);
|
);
|
||||||
const canClaim = referralTotal >= needed;
|
const canClaim = referralTotal >= needed;
|
||||||
const remaining = Math.max(0, needed - referralTotal);
|
const remaining = Math.max(0, needed - referralTotal);
|
||||||
const onlineFriends = [
|
const onlineFriends = friends.filter((friend) => friend.online === "1");
|
||||||
...new Map(
|
|
||||||
friends
|
|
||||||
.filter((friend) => friend.online === "1")
|
|
||||||
.map((friend) => [friend.id, friend]),
|
|
||||||
).values(),
|
|
||||||
];
|
|
||||||
const date = (value: number) =>
|
const date = (value: number) =>
|
||||||
value
|
value
|
||||||
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
|
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions";
|
|||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
|
import { articleSlug } from "@/lib/article-input";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cachedQuery } from "@/lib/cached-db";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
User,
|
User,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
comment?: string;
|
comment?: string;
|
||||||
@@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
|||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string[] }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||||
const { slug } = await params;
|
const slug = articleSlug((await params).slug);
|
||||||
const [article] = await db
|
const [article] = await db
|
||||||
.select({
|
.select({
|
||||||
title: WebsiteArticles.title,
|
title: WebsiteArticles.title,
|
||||||
@@ -95,44 +96,40 @@ export default async function ArticlePage({
|
|||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string[] }>;
|
||||||
searchParams: SearchParams;
|
searchParams: SearchParams;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const slug = articleSlug((await params).slug);
|
||||||
const { comment, reaction, error } = await searchParams;
|
const { comment, reaction, error } = await searchParams;
|
||||||
const t = await getTranslations("pages.article");
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
const article = await cachedQuery(
|
const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
|
||||||
`article:slug:${slug}`,
|
const [row] = await db
|
||||||
async () => {
|
.select({
|
||||||
const [row] = await db
|
id: WebsiteArticles.id,
|
||||||
.select({
|
slug: WebsiteArticles.slug,
|
||||||
id: WebsiteArticles.id,
|
title: WebsiteArticles.title,
|
||||||
slug: WebsiteArticles.slug,
|
shortStory: WebsiteArticles.shortStory,
|
||||||
title: WebsiteArticles.title,
|
fullStory: WebsiteArticles.fullStory,
|
||||||
shortStory: WebsiteArticles.shortStory,
|
image: WebsiteArticles.image,
|
||||||
fullStory: WebsiteArticles.fullStory,
|
createdAt: WebsiteArticles.createdAt,
|
||||||
image: WebsiteArticles.image,
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
createdAt: WebsiteArticles.createdAt,
|
})
|
||||||
updatedAt: WebsiteArticles.updatedAt,
|
.from(WebsiteArticles)
|
||||||
})
|
.where(
|
||||||
.from(WebsiteArticles)
|
and(
|
||||||
.where(
|
eq(WebsiteArticles.slug, slug),
|
||||||
and(
|
eq(WebsiteArticles.status, "published"),
|
||||||
eq(WebsiteArticles.slug, slug),
|
or(
|
||||||
eq(WebsiteArticles.status, "published"),
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
or(
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
|
||||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.limit(1)
|
)
|
||||||
.catch(() => []);
|
.limit(1)
|
||||||
return row ?? null;
|
.catch(() => []);
|
||||||
},
|
return row ?? null;
|
||||||
60,
|
});
|
||||||
);
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
const articleId = article.id;
|
const articleId = article.id;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default async function EditArticle({
|
|||||||
title: WebsiteArticles.title,
|
title: WebsiteArticles.title,
|
||||||
shortStory: WebsiteArticles.shortStory,
|
shortStory: WebsiteArticles.shortStory,
|
||||||
fullStory: WebsiteArticles.fullStory,
|
fullStory: WebsiteArticles.fullStory,
|
||||||
|
status: WebsiteArticles.status,
|
||||||
|
publishAt: WebsiteArticles.publishAt,
|
||||||
image: WebsiteArticles.image,
|
image: WebsiteArticles.image,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
@@ -63,7 +65,7 @@ export default async function EditArticle({
|
|||||||
action={async (fd) => {
|
action={async (fd) => {
|
||||||
"use server";
|
"use server";
|
||||||
fd.set("id", String(article.id));
|
fd.set("id", String(article.id));
|
||||||
await updateArticle(fd);
|
return updateArticle(fd);
|
||||||
}}
|
}}
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
title: article.title,
|
title: article.title,
|
||||||
@@ -71,6 +73,8 @@ export default async function EditArticle({
|
|||||||
image: article.image,
|
image: article.image,
|
||||||
shortStory: article.shortStory,
|
shortStory: article.shortStory,
|
||||||
fullStory: article.fullStory,
|
fullStory: article.fullStory,
|
||||||
|
status: article.status,
|
||||||
|
publishAt: article.publishAt?.toISOString(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { and, eq, or, sql } from "drizzle-orm";
|
import { and, eq, or, sql } from "drizzle-orm";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/articles/:slug — single website_article by slug, including the
|
* GET /api/articles/:slug — single website_article by slug, including the
|
||||||
@@ -14,9 +16,9 @@ export async function GET(
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await redisCache(
|
const data = await cacheNews(
|
||||||
apiCacheKey(`articles:${slug}`),
|
apiCacheKey(`articles:${slug}`),
|
||||||
60,
|
60_000,
|
||||||
async () => {
|
async () => {
|
||||||
const [article] = await db
|
const [article] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -55,7 +57,8 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
// DB unavailable — treat as not found rather than a 500.
|
// DB unavailable — treat as not found rather than a 500.
|
||||||
return apiJson({ error: "Article not found" }, { status: 200 });
|
return apiJson({ error: "Article not found" }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { apiJson, pagination } from "@/lib/api";
|
import { apiJson, pagination } from "@/lib/api";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/articles — paginated list of website_articles, newest first.
|
* GET /api/articles — paginated list of website_articles, newest first.
|
||||||
@@ -13,9 +15,9 @@ export async function GET(req: Request) {
|
|||||||
const { page, perPage, skip, take } = pagination(sp);
|
const { page, perPage, skip, take } = pagination(sp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await redisCache(
|
const data = await cacheNews(
|
||||||
apiCacheKey(`articles:${page}:${perPage}`),
|
apiCacheKey(`articles:${page}:${perPage}`),
|
||||||
60,
|
60_000,
|
||||||
async () => {
|
async () => {
|
||||||
const publishedFilter = and(
|
const publishedFilter = and(
|
||||||
eq(WebsiteArticles.status, "published"),
|
eq(WebsiteArticles.status, "published"),
|
||||||
@@ -60,7 +62,8 @@ export async function GET(req: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
// DB unavailable — return an empty payload instead of a 500.
|
// DB unavailable — return an empty payload instead of a 500.
|
||||||
return apiJson(
|
return apiJson(
|
||||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { env } from "@/env";
|
|||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/home — combined landing payload: the latest 4 website_articles and
|
* GET /api/home — combined landing payload: the latest 4 website_articles and
|
||||||
@@ -12,7 +14,7 @@ import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
|||||||
*/
|
*/
|
||||||
export async function GET(_req: Request) {
|
export async function GET(_req: Request) {
|
||||||
try {
|
try {
|
||||||
const data = await redisCache(apiCacheKey("home"), 15, async () => {
|
const data = await cacheNews(apiCacheKey("home"), 15_000, async () => {
|
||||||
const [articles, onlineRows, hotelName] = await Promise.all([
|
const [articles, onlineRows, hotelName] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -45,7 +47,8 @@ export async function GET(_req: Request) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
return apiJson(
|
return apiJson(
|
||||||
{ articles: [], online: 0, hotelName: env.HOTEL_NAME },
|
{ articles: [], online: 0, hotelName: env.HOTEL_NAME },
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useRef, useState, useTransition } from "react";
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||||
|
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||||
import { MediaPicker } from "./media-picker";
|
import { MediaPicker } from "./media-picker";
|
||||||
@@ -12,7 +14,7 @@ export function ArticleForm({
|
|||||||
action,
|
action,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
}: {
|
}: {
|
||||||
action: (formData: FormData) => Promise<void>;
|
action: (formData: FormData) => Promise<ArticleSaveResult | void>;
|
||||||
edit?: boolean;
|
edit?: boolean;
|
||||||
defaultValues?: {
|
defaultValues?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -28,7 +30,8 @@ export function ArticleForm({
|
|||||||
const tAction = useTranslations("pages.admin.actions");
|
const tAction = useTranslations("pages.admin.actions");
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [saveError, setSaveError] = useState(false);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
const saving = useRef(false);
|
const saving = useRef(false);
|
||||||
const editVersion = useRef(0);
|
const editVersion = useRef(0);
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
@@ -42,6 +45,14 @@ export function ArticleForm({
|
|||||||
const [image, setImage] = useState(defaultValues?.image ?? "");
|
const [image, setImage] = useState(defaultValues?.image ?? "");
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
||||||
|
const [publishAt, setPublishAt] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultValues?.publishAt) return;
|
||||||
|
const date = new Date(defaultValues.publishAt);
|
||||||
|
if (Number.isNaN(date.getTime())) return;
|
||||||
|
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||||
|
setPublishAt(local.toISOString().slice(0, 16));
|
||||||
|
}, [defaultValues?.publishAt]);
|
||||||
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||||
@@ -55,13 +66,24 @@ export function ArticleForm({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving.current) return;
|
if (saving.current) return;
|
||||||
const data = new FormData(event.currentTarget);
|
const data = new FormData(event.currentTarget);
|
||||||
|
if (status === "scheduled" && publishAt) {
|
||||||
|
const date = new Date(publishAt);
|
||||||
|
if (!Number.isNaN(date.getTime()))
|
||||||
|
data.set("publishAt", date.toISOString());
|
||||||
|
}
|
||||||
const submittedVersion = editVersion.current;
|
const submittedVersion = editVersion.current;
|
||||||
saving.current = true;
|
saving.current = true;
|
||||||
setSaveError(false);
|
setSaveError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await action(data);
|
const result = await action(data);
|
||||||
|
if (result && !result.ok) {
|
||||||
|
setSaveError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (editVersion.current === submittedVersion) setDirty(false);
|
if (editVersion.current === submittedVersion) setDirty(false);
|
||||||
|
if (result?.ok && result.data?.redirectTo)
|
||||||
|
router.push(result.data.redirectTo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error &&
|
error &&
|
||||||
@@ -72,7 +94,7 @@ export function ArticleForm({
|
|||||||
setDirty(false);
|
setDirty(false);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
setSaveError(true);
|
setSaveError(tAction("saveError"));
|
||||||
} finally {
|
} finally {
|
||||||
saving.current = false;
|
saving.current = false;
|
||||||
}
|
}
|
||||||
@@ -84,7 +106,7 @@ export function ArticleForm({
|
|||||||
<fieldset disabled={pending} className="contents">
|
<fieldset disabled={pending} className="contents">
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<p role="alert" className="text-sm">
|
<p role="alert" className="text-sm">
|
||||||
{tAction("saveError")}
|
{saveError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -94,6 +116,7 @@ export function ArticleForm({
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
name="title"
|
name="title"
|
||||||
|
maxLength={255}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder={t("titlePlaceholder")}
|
placeholder={t("titlePlaceholder")}
|
||||||
@@ -108,6 +131,7 @@ export function ArticleForm({
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
name="slug"
|
name="slug"
|
||||||
|
maxLength={255}
|
||||||
value={slugTouched ? slug : suggestedSlug}
|
value={slugTouched ? slug : suggestedSlug}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSlug(e.target.value);
|
setSlug(e.target.value);
|
||||||
@@ -127,6 +151,7 @@ export function ArticleForm({
|
|||||||
<input
|
<input
|
||||||
aria-label={t("image")}
|
aria-label={t("image")}
|
||||||
name="image"
|
name="image"
|
||||||
|
maxLength={255}
|
||||||
value={image}
|
value={image}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setImage(e.target.value);
|
setImage(e.target.value);
|
||||||
@@ -148,7 +173,7 @@ export function ArticleForm({
|
|||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt="preview"
|
alt={t("imagePreview")}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
||||||
style={{ maxHeight: 160 }}
|
style={{ maxHeight: 160 }}
|
||||||
@@ -156,17 +181,18 @@ export function ArticleForm({
|
|||||||
) : null}
|
) : null}
|
||||||
{imageError ? (
|
{imageError ? (
|
||||||
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
||||||
Image not found — enter a valid URL or browse.
|
{t("imageNotFound")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
{t("body")}
|
{t("summary")}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
name="shortStory"
|
name="shortStory"
|
||||||
|
maxLength={255}
|
||||||
defaultValue={defaultValues?.shortStory ?? ""}
|
defaultValue={defaultValues?.shortStory ?? ""}
|
||||||
placeholder={t("bodyPlaceholder")}
|
placeholder={t("bodyPlaceholder")}
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -212,7 +238,8 @@ export function ArticleForm({
|
|||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
name="publishAt"
|
name="publishAt"
|
||||||
defaultValue={defaultValues?.publishAt ?? ""}
|
value={publishAt}
|
||||||
|
onChange={(e) => setPublishAt(e.target.value)}
|
||||||
required
|
required
|
||||||
className="input input-bordered mt-1 w-full"
|
className="input input-bordered mt-1 w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -28,6 +29,7 @@ export function ArticlePreview({
|
|||||||
data: ArticlePreviewData | null;
|
data: ArticlePreviewData | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.articles.form");
|
||||||
const document = data
|
const document = data
|
||||||
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
|
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
|
||||||
: "";
|
: "";
|
||||||
@@ -40,13 +42,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>Article preview</DialogTitle>
|
<DialogTitle>{t("previewTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Content preview. The published page applies the site theme.
|
{t("previewDescription")}
|
||||||
</p>
|
</p>
|
||||||
<iframe
|
<iframe
|
||||||
title="Article content preview"
|
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,27 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { articleSlug, readArticleInput } from "./article-input";
|
||||||
|
|
||||||
|
it("normalizes Next catch-all slug parameters", () => {
|
||||||
|
expect(articleSlug(["welcome"])).toBe("welcome");
|
||||||
|
expect(articleSlug("welcome")).toBe("welcome");
|
||||||
|
});
|
||||||
|
it("keeps the selected timezone instant when scheduling", () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("title", "News");
|
||||||
|
form.set("status", "scheduled");
|
||||||
|
form.set("publishAt", "2026-09-06T20:00:00+02:00");
|
||||||
|
expect(readArticleInput(form).publishAt?.toISOString()).toBe(
|
||||||
|
"2026-09-06T18:00:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("ships additive publication columns for existing installations", () => {
|
||||||
|
const sql = readFileSync(
|
||||||
|
"drizzle/migrations/0025_article_publication.sql",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const column of ["status", "publish_at", "published_at"])
|
||||||
|
expect(sql).toContain(`ADD COLUMN IF NOT EXISTS ${column}`);
|
||||||
|
expect(sql).toContain("DEFAULT 'published'");
|
||||||
|
expect(sql).not.toMatch(/DROP|UPDATE\s+website_articles/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ActionResult } from "./safe-action-shared";
|
||||||
|
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||||
|
export class ArticleInputError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code:
|
||||||
|
| "titleRequired"
|
||||||
|
| "titleTooLong"
|
||||||
|
| "summaryTooLong"
|
||||||
|
| "imageTooLong"
|
||||||
|
| "invalidStatus"
|
||||||
|
| "publishDateRequired",
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function readArticleInput(form: FormData) {
|
||||||
|
const text = (key: string) =>
|
||||||
|
String(form.get(key) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const title = text("title"),
|
||||||
|
shortStory = text("shortStory"),
|
||||||
|
fullStory = text("fullStory"),
|
||||||
|
image = text("image");
|
||||||
|
if (!title) throw new ArticleInputError("titleRequired");
|
||||||
|
if (title.length > 255) throw new ArticleInputError("titleTooLong");
|
||||||
|
if (shortStory.length > 255) throw new ArticleInputError("summaryTooLong");
|
||||||
|
if (image.length > 255) throw new ArticleInputError("imageTooLong");
|
||||||
|
const status = text("status") || "published";
|
||||||
|
if (status !== "published" && status !== "draft" && status !== "scheduled")
|
||||||
|
throw new ArticleInputError("invalidStatus");
|
||||||
|
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||||
|
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||||
|
throw new ArticleInputError("publishDateRequired");
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
shortStory,
|
||||||
|
fullStory,
|
||||||
|
image,
|
||||||
|
status,
|
||||||
|
publishAt,
|
||||||
|
rawSlug: text("slug"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function articleSlug(value: string | string[]): string {
|
||||||
|
return Array.isArray(value) ? value.join("/") : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
revision: "0",
|
||||||
|
values: new Map<string, unknown>(),
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
status: "ready",
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/redis", () => ({ redis: state }));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/cache", () => ({
|
||||||
|
cached: async (key: string, _ttl: number, query: () => Promise<unknown>) => {
|
||||||
|
if (state.values.has(key)) return state.values.get(key);
|
||||||
|
const result = await query();
|
||||||
|
state.values.set(key, result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { cacheNews, invalidateNewsCache } from "./news-cache";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.values.clear();
|
||||||
|
state.revision = "0";
|
||||||
|
state.status = "ready";
|
||||||
|
state.get.mockReset().mockImplementation(async () => state.revision);
|
||||||
|
state.set
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (_key: string, value: string) => {
|
||||||
|
state.revision = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("invalidates a previously cached public list", async () => {
|
||||||
|
const query = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce(["new article"]);
|
||||||
|
expect(await cacheNews("list", 60000, query)).toEqual([]);
|
||||||
|
await invalidateNewsCache();
|
||||||
|
expect(await cacheNews("list", 60000, query)).toEqual(["new article"]);
|
||||||
|
});
|
||||||
|
it("does not let a stale in-flight read replace a newer revision", async () => {
|
||||||
|
let finish!: (value: string[]) => void;
|
||||||
|
const old = cacheNews(
|
||||||
|
"list",
|
||||||
|
60000,
|
||||||
|
() =>
|
||||||
|
new Promise<string[]>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||||
|
await invalidateNewsCache();
|
||||||
|
expect(await cacheNews("list", 60000, async () => ["new"])).toEqual(["new"]);
|
||||||
|
finish([]);
|
||||||
|
await old;
|
||||||
|
expect(await cacheNews("list", 60000, async () => ["wrong"])).toEqual([
|
||||||
|
"new",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("reads fresh data when Redis is unavailable", async () => {
|
||||||
|
state.get.mockRejectedValue(Error("offline"));
|
||||||
|
expect(await cacheNews("list", 60000, async () => ["fresh"])).toEqual([
|
||||||
|
"fresh",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { cached } from "@/lib/cache";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
const REVISION_KEY = "cms:news:revision";
|
||||||
|
export async function cacheNews<T>(
|
||||||
|
key: string,
|
||||||
|
ttlMs: number,
|
||||||
|
query: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!redis || redis.status === "end") return query();
|
||||||
|
let revision: string;
|
||||||
|
try {
|
||||||
|
revision = (await redis.get(REVISION_KEY)) ?? "0";
|
||||||
|
} catch {
|
||||||
|
return query();
|
||||||
|
}
|
||||||
|
return cached(`news:${revision}:${key}`, ttlMs, query);
|
||||||
|
}
|
||||||
|
export async function invalidateNewsCache(): Promise<void> {
|
||||||
|
if (!redis || redis.status === "end") return;
|
||||||
|
try {
|
||||||
|
await redis.set(REVISION_KEY, randomUUID());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("News saved but public cache invalidation failed", {
|
||||||
|
module: "news",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { cached } from "@/lib/cache";
|
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
|
|
||||||
export interface NewsListItem {
|
export interface NewsListItem {
|
||||||
@@ -32,7 +33,7 @@ const FETCH_LIMIT = 30;
|
|||||||
*/
|
*/
|
||||||
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||||
try {
|
try {
|
||||||
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
slug: WebsiteArticles.slug,
|
slug: WebsiteArticles.slug,
|
||||||
@@ -60,7 +61,8 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
|||||||
createdAt: a.createdAt ? new Date(a.createdAt) : null,
|
createdAt: a.createdAt ? new Date(a.createdAt) : null,
|
||||||
}))
|
}))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
friends: [] as { id: number; username: string; online: string }[],
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
|
||||||
|
vi.mock("./site-settings", () => ({
|
||||||
|
siteSettings: {
|
||||||
|
get: async (_key: string, fallback: string) => fallback,
|
||||||
|
getMany: async () => ({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
select: (fields: Record<string, unknown>) => {
|
||||||
|
let rows: unknown[] = [];
|
||||||
|
const chain = {
|
||||||
|
from: (table: unknown) => {
|
||||||
|
if (table === schema.MessengerFriendships)
|
||||||
|
rows =
|
||||||
|
"value" in fields
|
||||||
|
? [{ value: state.friends.length }]
|
||||||
|
: state.friends;
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
where: () => chain,
|
||||||
|
innerJoin: () => chain,
|
||||||
|
orderBy: () => chain,
|
||||||
|
limit: () => chain,
|
||||||
|
catch: () => Promise.resolve(rows),
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
|
||||||
|
then: (resolve: (value: unknown[]) => unknown) =>
|
||||||
|
Promise.resolve(rows).then(resolve),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { loadUserDashboard } from "./user-dashboard";
|
||||||
|
|
||||||
|
describe("dashboard friendships", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.friends = [];
|
||||||
|
});
|
||||||
|
it("counts each friend once when both directional rows are returned", async () => {
|
||||||
|
const wait = { id: 2, username: "wait", online: "1" };
|
||||||
|
const other = { id: 3, username: "YouKnowMe", online: "1" };
|
||||||
|
state.friends = [wait, other, { ...wait }, { ...other }];
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends.map((friend) => friend.id)).toEqual([2, 3]);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||||
|
});
|
||||||
|
it("preserves single-direction and offline friendships", async () => {
|
||||||
|
state.friends = [
|
||||||
|
{ id: 2, username: "wait", online: "1" },
|
||||||
|
{ id: 3, username: "offline", online: "0" },
|
||||||
|
];
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends).toEqual(state.friends);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||||
|
});
|
||||||
|
it("excludes self friendships", async () => {
|
||||||
|
state.friends = [{ id: 1, username: "me", online: "1" }];
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends).toEqual([]);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||||
|
});
|
||||||
|
it("returns zero for an empty friends list", async () => {
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends).toEqual([]);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
recentRooms,
|
recentRooms,
|
||||||
badges,
|
badges,
|
||||||
userSettingsRows,
|
userSettingsRows,
|
||||||
friendCountRows,
|
|
||||||
unreadCountRows,
|
unreadCountRows,
|
||||||
referralsRows,
|
referralsRows,
|
||||||
friends,
|
friends,
|
||||||
@@ -82,16 +81,6 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
.where(eq(UsersSettings.userId, userId))
|
.where(eq(UsersSettings.userId, userId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
db
|
|
||||||
.select({ value: count() })
|
|
||||||
.from(MessengerFriendships)
|
|
||||||
.where(
|
|
||||||
or(
|
|
||||||
eq(MessengerFriendships.userOneId, userId),
|
|
||||||
eq(MessengerFriendships.userTwoId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => [{ value: 0 }]),
|
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(MessengerOffline)
|
.from(MessengerOffline)
|
||||||
@@ -145,6 +134,15 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Each friendship can have two directional rows; expose users, not edges.
|
||||||
|
const uniqueFriends = [
|
||||||
|
...new Map(
|
||||||
|
friends
|
||||||
|
.filter((friend) => friend.id !== userId)
|
||||||
|
.map((friend) => [friend.id, friend]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userRows,
|
userRows,
|
||||||
hotelName,
|
hotelName,
|
||||||
@@ -155,10 +153,10 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
recentRooms,
|
recentRooms,
|
||||||
badges,
|
badges,
|
||||||
userSettingsRows,
|
userSettingsRows,
|
||||||
friendCountRows,
|
friendCountRows: [{ value: uniqueFriends.length }],
|
||||||
unreadCountRows,
|
unreadCountRows,
|
||||||
referralsRows,
|
referralsRows,
|
||||||
friends,
|
friends: uniqueFriends,
|
||||||
currencyRows,
|
currencyRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+15
-1
@@ -1161,7 +1161,21 @@
|
|||||||
"statusScheduled": "Scheduled",
|
"statusScheduled": "Scheduled",
|
||||||
"statusPublished": "Published",
|
"statusPublished": "Published",
|
||||||
"publishAt": "Publish at",
|
"publishAt": "Publish at",
|
||||||
"publishedAt": "Published at"
|
"publishedAt": "Published at",
|
||||||
|
"titleRequired": "Enter a title.",
|
||||||
|
"titleTooLong": "The title must be at most 255 characters.",
|
||||||
|
"summaryTooLong": "The summary must be at most 255 characters.",
|
||||||
|
"imageTooLong": "The image URL must be at most 255 characters.",
|
||||||
|
"invalidStatus": "Select a valid publication status.",
|
||||||
|
"publishDateRequired": "Choose a valid publication date.",
|
||||||
|
"saveFailedReference": "Could not save the article. Your content is preserved. Error reference: {reference}",
|
||||||
|
"articleNotFound": "Article not found.",
|
||||||
|
"summary": "Summary",
|
||||||
|
"imageNotFound": "Image not found. Enter a valid URL or browse.",
|
||||||
|
"imagePreview": "Article image preview",
|
||||||
|
"previewTitle": "Article preview",
|
||||||
|
"previewDescription": "Content preview. The published page applies the site theme.",
|
||||||
|
"previewFrame": "Article content preview"
|
||||||
},
|
},
|
||||||
"articlesShown": "Articles shown",
|
"articlesShown": "Articles shown",
|
||||||
"latest": "Latest article",
|
"latest": "Latest article",
|
||||||
|
|||||||
+15
-1
@@ -1135,7 +1135,21 @@
|
|||||||
"statusScheduled": "Programmato",
|
"statusScheduled": "Programmato",
|
||||||
"statusPublished": "Pubblicato",
|
"statusPublished": "Pubblicato",
|
||||||
"publishAt": "Data di pubblicazione",
|
"publishAt": "Data di pubblicazione",
|
||||||
"publishedAt": "Pubblicato il"
|
"publishedAt": "Pubblicato il",
|
||||||
|
"titleRequired": "Inserisci un titolo.",
|
||||||
|
"titleTooLong": "Il titolo può contenere al massimo 255 caratteri.",
|
||||||
|
"summaryTooLong": "Il sommario può contenere al massimo 255 caratteri.",
|
||||||
|
"imageTooLong": "L’URL dell’immagine può contenere al massimo 255 caratteri.",
|
||||||
|
"invalidStatus": "Seleziona uno stato di pubblicazione valido.",
|
||||||
|
"publishDateRequired": "Scegli una data di pubblicazione valida.",
|
||||||
|
"saveFailedReference": "Impossibile salvare la news. Il contenuto è conservato. Riferimento errore: {reference}",
|
||||||
|
"articleNotFound": "News non trovata.",
|
||||||
|
"summary": "Sommario",
|
||||||
|
"imageNotFound": "Immagine non trovata. Inserisci un URL valido o sfoglia i media.",
|
||||||
|
"imagePreview": "Anteprima immagine della news",
|
||||||
|
"previewTitle": "Anteprima news",
|
||||||
|
"previewDescription": "Anteprima del contenuto. La pagina pubblicata usa il tema del sito.",
|
||||||
|
"previewFrame": "Anteprima contenuto della news"
|
||||||
},
|
},
|
||||||
"articlesShown": "Articoli mostrati",
|
"articlesShown": "Articoli mostrati",
|
||||||
"latest": "Ultimo articolo",
|
"latest": "Ultimo articolo",
|
||||||
|
|||||||
+15
-1
@@ -1123,7 +1123,21 @@
|
|||||||
"saving": "Opslaan…",
|
"saving": "Opslaan…",
|
||||||
"deleteArticle": "Artikel verwijderen",
|
"deleteArticle": "Artikel verwijderen",
|
||||||
"deleteConfirm": "Deze actie kan niet ongedaan worden gemaakt. Het artikel wordt permanent verwijderd.",
|
"deleteConfirm": "Deze actie kan niet ongedaan worden gemaakt. Het artikel wordt permanent verwijderd.",
|
||||||
"cancel": "Annuleren"
|
"cancel": "Annuleren",
|
||||||
|
"titleRequired": "Voer een titel in.",
|
||||||
|
"titleTooLong": "De titel mag maximaal 255 tekens bevatten.",
|
||||||
|
"summaryTooLong": "De samenvatting mag maximaal 255 tekens bevatten.",
|
||||||
|
"imageTooLong": "De afbeeldings-URL mag maximaal 255 tekens bevatten.",
|
||||||
|
"invalidStatus": "Selecteer een geldige publicatiestatus.",
|
||||||
|
"publishDateRequired": "Kies een geldige publicatiedatum.",
|
||||||
|
"saveFailedReference": "Het artikel kon niet worden opgeslagen. Je inhoud blijft bewaard. Foutreferentie: {reference}",
|
||||||
|
"articleNotFound": "Artikel niet gevonden.",
|
||||||
|
"summary": "Samenvatting",
|
||||||
|
"imageNotFound": "Afbeelding niet gevonden. Voer een geldige URL in of blader door media.",
|
||||||
|
"imagePreview": "Voorbeeld van artikelafbeelding",
|
||||||
|
"previewTitle": "Artikelvoorbeeld",
|
||||||
|
"previewDescription": "Voorbeeld van de inhoud. De gepubliceerde pagina gebruikt het sitethema.",
|
||||||
|
"previewFrame": "Voorbeeld van artikelinhoud"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
Reference in new issue
Block a user