- Remove .prettierrc (dead config, Biome replaces Prettier) - Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat - Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit - Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts - Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars - Replace barrel export src/types/index.ts with direct @/types/common imports - Make trustHost conditional (development only) in auth.ts - Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations - Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
141 lines
3.6 KiB
TypeScript
141 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useTranslations } from "next-intl";
|
|
import { useMemo, useState } from "react";
|
|
import { slugify } from "@/lib/format";
|
|
import { MediaPicker } from "./media-picker";
|
|
import { RichText } from "./rich-text";
|
|
|
|
export function ArticleForm({
|
|
action,
|
|
defaultValues,
|
|
}: {
|
|
action: (formData: FormData) => Promise<void>;
|
|
edit?: boolean;
|
|
defaultValues?: {
|
|
title?: string;
|
|
slug?: string;
|
|
image?: string;
|
|
shortStory?: string;
|
|
fullStory?: string;
|
|
};
|
|
}) {
|
|
const t = useTranslations("pages.admin.articles.form");
|
|
const [title, setTitle] = useState(defaultValues?.title ?? "");
|
|
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
|
|
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
|
const [image, setImage] = useState(defaultValues?.image ?? "");
|
|
const [imageError, setImageError] = useState(false);
|
|
|
|
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
|
|
|
return (
|
|
<form
|
|
action={action}
|
|
className="admin-card"
|
|
style={{ display: "grid", gap: "1rem" }}
|
|
>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
{t("title")}
|
|
</span>
|
|
<input
|
|
name="title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t("titlePlaceholder")}
|
|
required
|
|
className="input input-bordered mt-1 w-full"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
{t("slug")}
|
|
</span>
|
|
<input
|
|
name="slug"
|
|
value={slugTouched ? slug : suggestedSlug}
|
|
onChange={(e) => {
|
|
setSlug(e.target.value);
|
|
setSlugTouched(true);
|
|
}}
|
|
placeholder={t("slugPlaceholder")}
|
|
className="input input-bordered mt-1 w-full"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="block">
|
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
{t("image")}
|
|
</span>
|
|
<div className="mt-1 flex gap-2 items-start">
|
|
<input
|
|
name="image"
|
|
value={image}
|
|
onChange={(e) => {
|
|
setImage(e.target.value);
|
|
setImageError(false);
|
|
}}
|
|
placeholder={t("imagePlaceholder")}
|
|
className="input input-bordered flex-1"
|
|
/>
|
|
<MediaPicker
|
|
onSelect={(url) => {
|
|
setImage(url);
|
|
setImageError(false);
|
|
}}
|
|
current={image}
|
|
/>
|
|
</div>
|
|
{image && !imageError ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={image}
|
|
alt="preview"
|
|
onError={() => setImageError(true)}
|
|
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
|
style={{ maxHeight: 160 }}
|
|
/>
|
|
) : null}
|
|
{imageError ? (
|
|
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
|
Image not found — enter a valid URL or browse.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
{t("body")}
|
|
</span>
|
|
<textarea
|
|
name="shortStory"
|
|
defaultValue={defaultValues?.shortStory ?? ""}
|
|
placeholder={t("bodyPlaceholder")}
|
|
rows={2}
|
|
className="input input-bordered mt-1 w-full resize-y"
|
|
/>
|
|
</label>
|
|
|
|
<div className="block">
|
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
{t("body")}
|
|
</span>
|
|
<RichText
|
|
name="fullStory"
|
|
defaultValue={defaultValues?.fullStory ?? ""}
|
|
placeholder={t("bodyPlaceholder")}
|
|
rows={12}
|
|
/>
|
|
</div>
|
|
|
|
<button type="submit" className="btn btn-primary justify-self-start">
|
|
{t("save")}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|