ADDED - Save an unfinished project as a draft

- Add a Save as draft button that bypasses the completeness gate: the user
  can save partial work and finish later
- projectDraftInputSchema makes the user-facing copy (title, summary, service
  label, client) optional; the action auto-generates a slug from the title
  (or draft-<timestamp>), defaults the year to the current year, and forces
  the project unpublished
- Drafts drop not-yet-valid sections/assets instead of failing the save
- Pass intent via a hidden field set on click so the server reliably sees it
- Tests: draft schema accepts empty copy a strict save rejects, and still
  requires a slug and a valid year
This commit is contained in:
moh
2026-09-20 16:52:13 +02:00
parent 39e459fa09
commit 4b535694bc
4 changed files with 164 additions and 14 deletions
+60 -10
View File
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
import {
assetInputSchema,
categoryInputSchema,
projectDraftInputSchema,
projectInputSchema,
sectionInputSchema,
} from "@/lib/portfolio-validation";
/** Build a URL-safe slug from a title, falling back to a unique draft slug. */
function slugifyForDraft(input: string): string {
const base = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base || `draft-${Date.now()}`;
}
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie();
@@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) {
const createdMediaAssetIds: string[] = [];
try {
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
const intent = String(formData.get("intent") ?? "save");
const isDraft = intent === "draft";
const parseSection = (section: Record<string, unknown>, index: number) =>
sectionInputSchema.parse({
...section,
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
sortOrder: section.sortOrder ?? index,
}),
);
});
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
const parseAsset = (asset: Record<string, unknown>, index: number) =>
assetInputSchema.parse({
...asset,
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
sortOrder: asset.sortOrder ?? index,
}),
);
});
// A draft keeps only the entries that are already valid; a full save
// validates every entry strictly.
const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => {
if (!isDraft) {
return [parseSection(section, index)];
}
try {
return [parseSection(section, index)];
} catch {
return [];
}
});
const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => {
if (!isDraft) {
return [parseAsset(asset, index)];
}
try {
return [parseAsset(asset, index)];
} catch {
return [];
}
});
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
const parsed = projectInputSchema.parse({
const rawSlug = String(formData.get("slug") ?? "").trim();
const slug =
isDraft && !rawSlug
? slugifyForDraft(
String(formData.get("titleDe") ?? "") ||
String(formData.get("titleEn") ?? "") ||
String(formData.get("titleAr") ?? ""),
)
: rawSlug;
const rawYear = String(formData.get("projectYear") ?? "").trim();
const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear;
const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({
id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""),
slug: String(formData.get("slug") ?? ""),
slug,
viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""),
@@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) {
summaryEn: String(formData.get("summaryEn") ?? ""),
summaryDe: String(formData.get("summaryDe") ?? ""),
clientName: String(formData.get("clientName") ?? ""),
projectYear: String(formData.get("projectYear") ?? ""),
projectYear,
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
@@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) {
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
sortOrder: String(formData.get("sortOrder") ?? "0"),
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
isPublished: normalizeCheckboxValue(formData, "isPublished"),
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
sections,
assets,
});