/** * @fileoverview Frontmatter form panel component for editing content metadata * * This component provides a collapsible panel on the right side for editing * frontmatter fields. It supports schema-aware dynamic fields when a collection * schema is available, or falls back to basic fields. * * @module @writenex/astro/client/components/FrontmatterForm */ import { AlertCircle, Info, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import type { CollectionSchema, SchemaField } from "../../../types"; import { useSharedApi } from "../../context/ApiContext"; import type { ContentSummary } from "../../hooks/useApi"; import "./FrontmatterForm.css"; /** * Props for the FrontmatterForm component */ interface FrontmatterFormProps { /** Whether the panel is open */ isOpen: boolean; /** Callback to close the panel */ onClose: () => void; /** Current frontmatter data */ frontmatter: Record | null; /** Collection schema for dynamic field generation */ schema?: CollectionSchema; /** Callback when frontmatter changes */ onChange: (frontmatter: Record) => void; /** Whether the form is disabled */ disabled?: boolean; /** Callback for image upload */ onImageUpload?: (file: File, fieldName: string) => Promise; /** Current collection name for image preview URLs */ collection?: string; /** Current content ID for image preview URLs */ contentId?: string; } /** * Frontmatter form panel for editing content metadata * * @component */ export function FrontmatterForm({ isOpen, onClose, frontmatter, schema, onChange, disabled = false, onImageUpload, collection, contentId, }: FrontmatterFormProps): React.ReactElement { const handleFieldChange = useCallback( (field: string, value: unknown) => { if (!frontmatter) return; onChange({ ...frontmatter, [field]: value }); }, [frontmatter, onChange] ); const panelClassName = [ "wn-frontmatter-panel", isOpen ? "wn-frontmatter-panel--open" : "wn-frontmatter-panel--closed", ] .filter(Boolean) .join(" "); const hasSchema = schema && Object.keys(schema).length > 0; const fieldCount = hasSchema ? Object.keys(schema).length : 0; return ( ); } /** * Empty state when no content is selected */ function EmptyState(): React.ReactElement { return (

No content selected

Select a content item from the sidebar to edit its frontmatter

); } /** * Priority order for common frontmatter fields. * Lower number = higher priority (appears first). */ const FIELD_PRIORITY: Record = { title: 1, name: 2, description: 10, excerpt: 11, summary: 12, date: 20, pubDate: 21, publishDate: 22, updatedDate: 23, modifiedDate: 24, author: 30, authors: 31, category: 40, categories: 41, tags: 42, image: 50, hero: 51, heroImage: 52, heroAlt: 53, cover: 54, coverImage: 55, thumbnail: 56, draft: 90, featured: 91, published: 92, }; /** * Get sort priority for a field name. * Fields not in priority list get a default value of 100. */ function getFieldPriority(fieldName: string): number { return FIELD_PRIORITY[fieldName] ?? 100; } /** * Schema-aware dynamic fields */ function SchemaFields({ frontmatter, schema, onChange, disabled, onImageUpload, collection, contentId, }: { frontmatter: Record; schema: CollectionSchema; onChange: (field: string, value: unknown) => void; disabled: boolean; onImageUpload?: (file: File, fieldName: string) => Promise; collection?: string; contentId?: string; }): React.ReactElement { const sortedFields = Object.entries(schema).sort( ([aKey, aField], [bKey, bField]) => { const aPriority = getFieldPriority(aKey); const bPriority = getFieldPriority(bKey); // Sort by priority first if (aPriority !== bPriority) return aPriority - bPriority; // Then by required status if (aField.required && !bField.required) return -1; if (!aField.required && bField.required) return 1; // Finally alphabetically return aKey.localeCompare(bKey); } ); return (
{sortedFields.map(([fieldName, fieldDef]) => ( onChange(fieldName, value)} disabled={disabled} onImageUpload={onImageUpload} collection={collection} contentId={contentId} /> ))}
); } /** * Basic fallback fields when no schema is available */ function BasicFields({ frontmatter, onChange, disabled, }: { frontmatter: Record; onChange: (field: string, value: unknown) => void; disabled: boolean; }): React.ReactElement { const handleTagsChange = (tagsString: string) => { const tags = tagsString .split(",") .map((t) => t.trim()) .filter(Boolean); onChange("tags", tags); }; const title = String(frontmatter.title ?? ""); const description = String(frontmatter.description ?? ""); const pubDate = formatDateForInput(frontmatter.pubDate); const updatedDate = formatDateForInput(frontmatter.updatedDate); const draft = Boolean(frontmatter.draft); const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags.join(", ") : ""; const heroImage = String(frontmatter.heroImage ?? ""); return (
{/* Title */}
onChange("title", e.target.value)} disabled={disabled} placeholder="Enter title" />
{/* Description */}