fix(news): restore publication flow and deduplicate dashboard friends
CI / check (push) Successful in 1m6s
CI / deploy (push) Successful in 59s

This commit is contained in:
Simo committed 2026-09-06 18:32:43 +02:00
1 parent ef94646d60
commit 9b0ea2fb16
22 files changed
+638 -195

No files matched your search

+38 -11
View File
@@ -1,8 +1,10 @@
"use client";
import { useRouter } from "next/navigation";
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 type { ArticleSaveResult } from "@/lib/article-input";
import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { MediaPicker } from "./media-picker";
@@ -12,7 +14,7 @@ export function ArticleForm({
action,
defaultValues,
}: {
action: (formData: FormData) => Promise<void>;
action: (formData: FormData) => Promise<ArticleSaveResult | void>;
edit?: boolean;
defaultValues?: {
title?: string;
@@ -28,7 +30,8 @@ export function ArticleForm({
const tAction = useTranslations("pages.admin.actions");
const [dirty, setDirty] = useState(false);
const [pending, startTransition] = useTransition();
const [saveError, setSaveError] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const router = useRouter();
const saving = useRef(false);
const editVersion = useRef(0);
function markDirty() {
@@ -42,6 +45,14 @@ export function ArticleForm({
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
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 [preview, setPreview] = useState<ArticlePreviewData | null>(null);
@@ -55,13 +66,24 @@ export function ArticleForm({
event.preventDefault();
if (saving.current) return;
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;
saving.current = true;
setSaveError(false);
setSaveError(null);
startTransition(async () => {
try {
await action(data);
const result = await action(data);
if (result && !result.ok) {
setSaveError(result.error);
return;
}
if (editVersion.current === submittedVersion) setDirty(false);
if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo);
} catch (error) {
if (
error &&
@@ -72,7 +94,7 @@ export function ArticleForm({
setDirty(false);
throw error;
}
setSaveError(true);
setSaveError(tAction("saveError"));
} finally {
saving.current = false;
}
@@ -84,7 +106,7 @@ export function ArticleForm({
<fieldset disabled={pending} className="contents">
{saveError && (
<p role="alert" className="text-sm">
{tAction("saveError")}
{saveError}
</p>
)}
<div className="grid gap-4 md:grid-cols-2">
@@ -94,6 +116,7 @@ export function ArticleForm({
</span>
<input
name="title"
maxLength={255}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
@@ -108,6 +131,7 @@ export function ArticleForm({
</span>
<input
name="slug"
maxLength={255}
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
@@ -127,6 +151,7 @@ export function ArticleForm({
<input
aria-label={t("image")}
name="image"
maxLength={255}
value={image}
onChange={(e) => {
setImage(e.target.value);
@@ -148,7 +173,7 @@ export function ArticleForm({
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
alt={t("imagePreview")}
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
@@ -156,17 +181,18 @@ export function ArticleForm({
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
{t("imageNotFound")}
</p>
) : null}
</div>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
{t("summary")}
</span>
<textarea
name="shortStory"
maxLength={255}
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
@@ -212,7 +238,8 @@ export function ArticleForm({
<input
type="datetime-local"
name="publishAt"
defaultValue={defaultValues?.publishAt ?? ""}
value={publishAt}
onChange={(e) => setPublishAt(e.target.value)}
required
className="input input-bordered mt-1 w-full"
/>
+5 -3
View File
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import {
Dialog,
DialogContent,
@@ -28,6 +29,7 @@ export function ArticlePreview({
data: ArticlePreviewData | null;
onClose: () => void;
}) {
const t = useTranslations("pages.admin.articles.form");
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>`
: "";
@@ -40,13 +42,13 @@ export function ArticlePreview({
>
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
<DialogHeader>
<DialogTitle>Article preview</DialogTitle>
<DialogTitle>{t("previewTitle")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
Content preview. The published page applies the site theme.
{t("previewDescription")}
</p>
<iframe
title="Article content preview"
title={t("previewFrame")}
sandbox=""
srcDoc={document}
className="min-h-0 flex-1 w-full bg-background rounded border"