@imjp/writenex-astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,914 @@
1
+ /**
2
+ * @fileoverview Frontmatter form panel component for editing content metadata
3
+ *
4
+ * This component provides a collapsible panel on the right side for editing
5
+ * frontmatter fields. It supports schema-aware dynamic fields when a collection
6
+ * schema is available, or falls back to basic fields.
7
+ *
8
+ * @module @writenex/astro/client/components/FrontmatterForm
9
+ */
10
+
11
+ import { useCallback, useState } from "react";
12
+ import { X, Info, AlertCircle } from "lucide-react";
13
+ import type { CollectionSchema, SchemaField } from "../../../types";
14
+ import "./FrontmatterForm.css";
15
+
16
+ /**
17
+ * Props for the FrontmatterForm component
18
+ */
19
+ interface FrontmatterFormProps {
20
+ /** Whether the panel is open */
21
+ isOpen: boolean;
22
+ /** Callback to close the panel */
23
+ onClose: () => void;
24
+ /** Current frontmatter data */
25
+ frontmatter: Record<string, unknown> | null;
26
+ /** Collection schema for dynamic field generation */
27
+ schema?: CollectionSchema;
28
+ /** Callback when frontmatter changes */
29
+ onChange: (frontmatter: Record<string, unknown>) => void;
30
+ /** Whether the form is disabled */
31
+ disabled?: boolean;
32
+ /** Callback for image upload */
33
+ onImageUpload?: (file: File, fieldName: string) => Promise<string | null>;
34
+ /** Current collection name for image preview URLs */
35
+ collection?: string;
36
+ /** Current content ID for image preview URLs */
37
+ contentId?: string;
38
+ }
39
+
40
+ /**
41
+ * Frontmatter form panel for editing content metadata
42
+ *
43
+ * @component
44
+ */
45
+ export function FrontmatterForm({
46
+ isOpen,
47
+ onClose,
48
+ frontmatter,
49
+ schema,
50
+ onChange,
51
+ disabled = false,
52
+ onImageUpload,
53
+ collection,
54
+ contentId,
55
+ }: FrontmatterFormProps): React.ReactElement {
56
+ const handleFieldChange = useCallback(
57
+ (field: string, value: unknown) => {
58
+ if (!frontmatter) return;
59
+ onChange({ ...frontmatter, [field]: value });
60
+ },
61
+ [frontmatter, onChange]
62
+ );
63
+
64
+ const panelClassName = [
65
+ "wn-frontmatter-panel",
66
+ isOpen ? "wn-frontmatter-panel--open" : "wn-frontmatter-panel--closed",
67
+ ]
68
+ .filter(Boolean)
69
+ .join(" ");
70
+
71
+ const hasSchema = schema && Object.keys(schema).length > 0;
72
+ const fieldCount = hasSchema ? Object.keys(schema).length : 0;
73
+
74
+ return (
75
+ <aside
76
+ className={panelClassName}
77
+ role="complementary"
78
+ aria-label="Frontmatter editor"
79
+ aria-hidden={!isOpen}
80
+ >
81
+ <div className="wn-frontmatter-panel-inner">
82
+ {/* Header */}
83
+ <div className="wn-frontmatter-header">
84
+ <h2 className="wn-frontmatter-title">
85
+ <Info size={14} />
86
+ Frontmatter
87
+ {frontmatter && (
88
+ <span className="wn-frontmatter-badge">
89
+ {hasSchema ? `${fieldCount} fields` : "Basic"}
90
+ </span>
91
+ )}
92
+ </h2>
93
+ <button
94
+ className="wn-frontmatter-close"
95
+ onClick={onClose}
96
+ title="Close panel"
97
+ aria-label="Close frontmatter panel"
98
+ >
99
+ <X size={12} />
100
+ </button>
101
+ </div>
102
+
103
+ {/* Content */}
104
+ <div className="wn-frontmatter-content">
105
+ {!frontmatter ? (
106
+ <EmptyState />
107
+ ) : hasSchema ? (
108
+ <SchemaFields
109
+ frontmatter={frontmatter}
110
+ schema={schema}
111
+ onChange={handleFieldChange}
112
+ disabled={disabled}
113
+ onImageUpload={onImageUpload}
114
+ collection={collection}
115
+ contentId={contentId}
116
+ />
117
+ ) : (
118
+ <BasicFields
119
+ frontmatter={frontmatter}
120
+ onChange={handleFieldChange}
121
+ disabled={disabled}
122
+ />
123
+ )}
124
+ </div>
125
+ </div>
126
+ </aside>
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Empty state when no content is selected
132
+ */
133
+ function EmptyState(): React.ReactElement {
134
+ return (
135
+ <div className="wn-frontmatter-empty">
136
+ <div className="wn-frontmatter-empty-icon">
137
+ <AlertCircle size={32} strokeWidth={1.5} />
138
+ </div>
139
+ <p className="wn-frontmatter-empty-text">No content selected</p>
140
+ <p className="wn-frontmatter-empty-hint">
141
+ Select a content item from the sidebar to edit its frontmatter
142
+ </p>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Priority order for common frontmatter fields.
149
+ * Lower number = higher priority (appears first).
150
+ */
151
+ const FIELD_PRIORITY: Record<string, number> = {
152
+ title: 1,
153
+ name: 2,
154
+ description: 10,
155
+ excerpt: 11,
156
+ summary: 12,
157
+ date: 20,
158
+ pubDate: 21,
159
+ publishDate: 22,
160
+ updatedDate: 23,
161
+ modifiedDate: 24,
162
+ author: 30,
163
+ authors: 31,
164
+ category: 40,
165
+ categories: 41,
166
+ tags: 42,
167
+ image: 50,
168
+ hero: 51,
169
+ heroImage: 52,
170
+ heroAlt: 53,
171
+ cover: 54,
172
+ coverImage: 55,
173
+ thumbnail: 56,
174
+ draft: 90,
175
+ featured: 91,
176
+ published: 92,
177
+ };
178
+
179
+ /**
180
+ * Get sort priority for a field name.
181
+ * Fields not in priority list get a default value of 100.
182
+ */
183
+ function getFieldPriority(fieldName: string): number {
184
+ return FIELD_PRIORITY[fieldName] ?? 100;
185
+ }
186
+
187
+ /**
188
+ * Schema-aware dynamic fields
189
+ */
190
+ function SchemaFields({
191
+ frontmatter,
192
+ schema,
193
+ onChange,
194
+ disabled,
195
+ onImageUpload,
196
+ collection,
197
+ contentId,
198
+ }: {
199
+ frontmatter: Record<string, unknown>;
200
+ schema: CollectionSchema;
201
+ onChange: (field: string, value: unknown) => void;
202
+ disabled: boolean;
203
+ onImageUpload?: (file: File, fieldName: string) => Promise<string | null>;
204
+ collection?: string;
205
+ contentId?: string;
206
+ }): React.ReactElement {
207
+ const sortedFields = Object.entries(schema).sort(
208
+ ([aKey, aField], [bKey, bField]) => {
209
+ const aPriority = getFieldPriority(aKey);
210
+ const bPriority = getFieldPriority(bKey);
211
+
212
+ // Sort by priority first
213
+ if (aPriority !== bPriority) return aPriority - bPriority;
214
+
215
+ // Then by required status
216
+ if (aField.required && !bField.required) return -1;
217
+ if (!aField.required && bField.required) return 1;
218
+
219
+ // Finally alphabetically
220
+ return aKey.localeCompare(bKey);
221
+ }
222
+ );
223
+
224
+ return (
225
+ <div className="wn-frontmatter-fields">
226
+ {sortedFields.map(([fieldName, fieldDef]) => (
227
+ <DynamicField
228
+ key={fieldName}
229
+ name={fieldName}
230
+ field={fieldDef}
231
+ value={frontmatter[fieldName]}
232
+ onChange={(value) => onChange(fieldName, value)}
233
+ disabled={disabled}
234
+ onImageUpload={onImageUpload}
235
+ collection={collection}
236
+ contentId={contentId}
237
+ />
238
+ ))}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Basic fallback fields when no schema is available
245
+ */
246
+ function BasicFields({
247
+ frontmatter,
248
+ onChange,
249
+ disabled,
250
+ }: {
251
+ frontmatter: Record<string, unknown>;
252
+ onChange: (field: string, value: unknown) => void;
253
+ disabled: boolean;
254
+ }): React.ReactElement {
255
+ const handleTagsChange = (tagsString: string) => {
256
+ const tags = tagsString
257
+ .split(",")
258
+ .map((t) => t.trim())
259
+ .filter(Boolean);
260
+ onChange("tags", tags);
261
+ };
262
+
263
+ const title = String(frontmatter.title ?? "");
264
+ const description = String(frontmatter.description ?? "");
265
+ const pubDate = formatDateForInput(frontmatter.pubDate);
266
+ const updatedDate = formatDateForInput(frontmatter.updatedDate);
267
+ const draft = Boolean(frontmatter.draft);
268
+ const tags = Array.isArray(frontmatter.tags)
269
+ ? frontmatter.tags.join(", ")
270
+ : "";
271
+ const heroImage = String(frontmatter.heroImage ?? "");
272
+
273
+ return (
274
+ <div className="wn-frontmatter-fields">
275
+ {/* Title */}
276
+ <div className="wn-frontmatter-field">
277
+ <label className="wn-frontmatter-label" htmlFor="fm-title">
278
+ Title<span className="wn-frontmatter-required">*</span>
279
+ </label>
280
+ <input
281
+ id="fm-title"
282
+ type="text"
283
+ className="wn-frontmatter-input"
284
+ value={title}
285
+ onChange={(e) => onChange("title", e.target.value)}
286
+ disabled={disabled}
287
+ placeholder="Enter title"
288
+ />
289
+ </div>
290
+
291
+ {/* Description */}
292
+ <div className="wn-frontmatter-field">
293
+ <label className="wn-frontmatter-label" htmlFor="fm-description">
294
+ Description
295
+ </label>
296
+ <textarea
297
+ id="fm-description"
298
+ className="wn-frontmatter-textarea"
299
+ value={description}
300
+ onChange={(e) => onChange("description", e.target.value)}
301
+ disabled={disabled}
302
+ placeholder="Brief description"
303
+ rows={2}
304
+ />
305
+ </div>
306
+
307
+ {/* Dates */}
308
+ <div className="wn-frontmatter-field--row">
309
+ <div className="wn-frontmatter-field">
310
+ <label className="wn-frontmatter-label" htmlFor="fm-pubDate">
311
+ Publish Date
312
+ </label>
313
+ <input
314
+ id="fm-pubDate"
315
+ type="date"
316
+ className="wn-frontmatter-input"
317
+ value={pubDate}
318
+ onChange={(e) => onChange("pubDate", e.target.value)}
319
+ disabled={disabled}
320
+ />
321
+ </div>
322
+ <div className="wn-frontmatter-field">
323
+ <label className="wn-frontmatter-label" htmlFor="fm-updatedDate">
324
+ Updated Date
325
+ </label>
326
+ <input
327
+ id="fm-updatedDate"
328
+ type="date"
329
+ className="wn-frontmatter-input"
330
+ value={updatedDate}
331
+ onChange={(e) =>
332
+ onChange("updatedDate", e.target.value || undefined)
333
+ }
334
+ disabled={disabled}
335
+ />
336
+ </div>
337
+ </div>
338
+
339
+ {/* Tags */}
340
+ <div className="wn-frontmatter-field">
341
+ <label className="wn-frontmatter-label" htmlFor="fm-tags">
342
+ Tags
343
+ </label>
344
+ <input
345
+ id="fm-tags"
346
+ type="text"
347
+ className="wn-frontmatter-input"
348
+ value={tags}
349
+ onChange={(e) => handleTagsChange(e.target.value)}
350
+ disabled={disabled}
351
+ placeholder="tag1, tag2, tag3"
352
+ />
353
+ <span className="wn-frontmatter-hint">Separate with commas</span>
354
+ </div>
355
+
356
+ {/* Hero Image */}
357
+ <div className="wn-frontmatter-field">
358
+ <label className="wn-frontmatter-label" htmlFor="fm-heroImage">
359
+ Hero Image
360
+ </label>
361
+ <input
362
+ id="fm-heroImage"
363
+ type="text"
364
+ className="wn-frontmatter-input"
365
+ value={heroImage}
366
+ onChange={(e) => onChange("heroImage", e.target.value || undefined)}
367
+ disabled={disabled}
368
+ placeholder="./images/hero.jpg"
369
+ />
370
+ </div>
371
+
372
+ <div className="wn-frontmatter-divider" />
373
+
374
+ {/* Draft */}
375
+ <div className="wn-frontmatter-checkbox-field">
376
+ <label className="wn-frontmatter-checkbox-label">
377
+ <input
378
+ type="checkbox"
379
+ className="wn-frontmatter-checkbox"
380
+ checked={draft}
381
+ onChange={(e) => onChange("draft", e.target.checked)}
382
+ disabled={disabled}
383
+ />
384
+ <span>Draft</span>
385
+ </label>
386
+ <span className="wn-frontmatter-checkbox-hint">Not published</span>
387
+ </div>
388
+ </div>
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Dynamic field renderer based on schema type
394
+ */
395
+ function DynamicField({
396
+ name,
397
+ field,
398
+ value,
399
+ onChange,
400
+ disabled,
401
+ onImageUpload,
402
+ collection,
403
+ contentId,
404
+ }: {
405
+ name: string;
406
+ field: SchemaField;
407
+ value: unknown;
408
+ onChange: (value: unknown) => void;
409
+ disabled: boolean;
410
+ onImageUpload?: (file: File, fieldName: string) => Promise<string | null>;
411
+ collection?: string;
412
+ contentId?: string;
413
+ }): React.ReactElement {
414
+ const fieldId = `fm-${name}`;
415
+ const label = formatFieldLabel(name);
416
+ const enumOptions = parseEnumFromDescription(field.description);
417
+
418
+ switch (field.type) {
419
+ case "boolean":
420
+ return (
421
+ <BooleanField
422
+ id={fieldId}
423
+ label={label}
424
+ value={Boolean(value ?? field.default)}
425
+ onChange={onChange}
426
+ disabled={disabled}
427
+ />
428
+ );
429
+
430
+ case "number":
431
+ return (
432
+ <NumberField
433
+ id={fieldId}
434
+ label={label}
435
+ value={value as number | undefined}
436
+ onChange={onChange}
437
+ disabled={disabled}
438
+ required={field.required}
439
+ />
440
+ );
441
+
442
+ case "date":
443
+ return (
444
+ <DateField
445
+ id={fieldId}
446
+ label={label}
447
+ value={value}
448
+ onChange={onChange}
449
+ disabled={disabled}
450
+ required={field.required}
451
+ />
452
+ );
453
+
454
+ case "array":
455
+ return (
456
+ <ArrayField
457
+ id={fieldId}
458
+ label={label}
459
+ value={value as unknown[] | undefined}
460
+ itemType={field.items}
461
+ onChange={onChange}
462
+ disabled={disabled}
463
+ required={field.required}
464
+ />
465
+ );
466
+
467
+ case "image":
468
+ return (
469
+ <ImageField
470
+ id={fieldId}
471
+ label={label}
472
+ value={value as string | undefined}
473
+ onChange={onChange}
474
+ disabled={disabled}
475
+ required={field.required}
476
+ onUpload={
477
+ onImageUpload ? (file) => onImageUpload(file, name) : undefined
478
+ }
479
+ collection={collection}
480
+ contentId={contentId}
481
+ />
482
+ );
483
+
484
+ case "string":
485
+ default:
486
+ if (enumOptions.length > 0) {
487
+ return (
488
+ <SelectField
489
+ id={fieldId}
490
+ label={label}
491
+ value={String(value ?? "")}
492
+ options={enumOptions}
493
+ onChange={onChange}
494
+ disabled={disabled}
495
+ required={field.required}
496
+ />
497
+ );
498
+ }
499
+
500
+ const isMultiline =
501
+ name === "description" || name === "excerpt" || name === "summary";
502
+
503
+ return (
504
+ <StringField
505
+ id={fieldId}
506
+ label={label}
507
+ value={String(value ?? "")}
508
+ onChange={onChange}
509
+ disabled={disabled}
510
+ required={field.required}
511
+ multiline={isMultiline}
512
+ />
513
+ );
514
+ }
515
+ }
516
+
517
+ // Field Components
518
+
519
+ interface BaseFieldProps {
520
+ id: string;
521
+ label: string;
522
+ disabled: boolean;
523
+ required?: boolean;
524
+ }
525
+
526
+ function StringField({
527
+ id,
528
+ label,
529
+ value,
530
+ onChange,
531
+ disabled,
532
+ required,
533
+ multiline,
534
+ }: BaseFieldProps & {
535
+ value: string;
536
+ onChange: (value: string) => void;
537
+ multiline?: boolean;
538
+ }): React.ReactElement {
539
+ return (
540
+ <div className="wn-frontmatter-field">
541
+ <label htmlFor={id} className="wn-frontmatter-label">
542
+ {label}
543
+ {required && <span className="wn-frontmatter-required">*</span>}
544
+ </label>
545
+ {multiline ? (
546
+ <textarea
547
+ id={id}
548
+ value={value}
549
+ onChange={(e) => onChange(e.target.value)}
550
+ disabled={disabled}
551
+ placeholder={`Enter ${label.toLowerCase()}`}
552
+ rows={2}
553
+ className="wn-frontmatter-textarea"
554
+ />
555
+ ) : (
556
+ <input
557
+ id={id}
558
+ type="text"
559
+ value={value}
560
+ onChange={(e) => onChange(e.target.value)}
561
+ disabled={disabled}
562
+ placeholder={`Enter ${label.toLowerCase()}`}
563
+ className="wn-frontmatter-input"
564
+ />
565
+ )}
566
+ </div>
567
+ );
568
+ }
569
+
570
+ function NumberField({
571
+ id,
572
+ label,
573
+ value,
574
+ onChange,
575
+ disabled,
576
+ required,
577
+ }: BaseFieldProps & {
578
+ value: number | undefined;
579
+ onChange: (value: number | undefined) => void;
580
+ }): React.ReactElement {
581
+ return (
582
+ <div className="wn-frontmatter-field">
583
+ <label htmlFor={id} className="wn-frontmatter-label">
584
+ {label}
585
+ {required && <span className="wn-frontmatter-required">*</span>}
586
+ </label>
587
+ <input
588
+ id={id}
589
+ type="number"
590
+ value={value ?? ""}
591
+ onChange={(e) => {
592
+ const val = e.target.value;
593
+ onChange(val === "" ? undefined : Number(val));
594
+ }}
595
+ disabled={disabled}
596
+ placeholder="0"
597
+ className="wn-frontmatter-input"
598
+ />
599
+ </div>
600
+ );
601
+ }
602
+
603
+ function BooleanField({
604
+ id,
605
+ label,
606
+ value,
607
+ onChange,
608
+ disabled,
609
+ }: BaseFieldProps & {
610
+ value: boolean;
611
+ onChange: (value: boolean) => void;
612
+ }): React.ReactElement {
613
+ return (
614
+ <div className="wn-frontmatter-checkbox-field">
615
+ <label className="wn-frontmatter-checkbox-label">
616
+ <input
617
+ id={id}
618
+ type="checkbox"
619
+ checked={value}
620
+ onChange={(e) => onChange(e.target.checked)}
621
+ disabled={disabled}
622
+ className="wn-frontmatter-checkbox"
623
+ />
624
+ <span>{label}</span>
625
+ </label>
626
+ </div>
627
+ );
628
+ }
629
+
630
+ function DateField({
631
+ id,
632
+ label,
633
+ value,
634
+ onChange,
635
+ disabled,
636
+ required,
637
+ }: BaseFieldProps & {
638
+ value: unknown;
639
+ onChange: (value: string | undefined) => void;
640
+ }): React.ReactElement {
641
+ const dateValue = formatDateForInput(value);
642
+
643
+ return (
644
+ <div className="wn-frontmatter-field">
645
+ <label htmlFor={id} className="wn-frontmatter-label">
646
+ {label}
647
+ {required && <span className="wn-frontmatter-required">*</span>}
648
+ </label>
649
+ <input
650
+ id={id}
651
+ type="date"
652
+ value={dateValue}
653
+ onChange={(e) => onChange(e.target.value || undefined)}
654
+ disabled={disabled}
655
+ className="wn-frontmatter-input"
656
+ />
657
+ </div>
658
+ );
659
+ }
660
+
661
+ function SelectField({
662
+ id,
663
+ label,
664
+ value,
665
+ options,
666
+ onChange,
667
+ disabled,
668
+ required,
669
+ }: BaseFieldProps & {
670
+ value: string;
671
+ options: string[];
672
+ onChange: (value: string) => void;
673
+ }): React.ReactElement {
674
+ return (
675
+ <div className="wn-frontmatter-field">
676
+ <label htmlFor={id} className="wn-frontmatter-label">
677
+ {label}
678
+ {required && <span className="wn-frontmatter-required">*</span>}
679
+ </label>
680
+ <select
681
+ id={id}
682
+ value={value}
683
+ onChange={(e) => onChange(e.target.value)}
684
+ disabled={disabled}
685
+ className="wn-frontmatter-select"
686
+ >
687
+ <option value="">Select {label.toLowerCase()}</option>
688
+ {options.map((opt) => (
689
+ <option key={opt} value={opt}>
690
+ {opt}
691
+ </option>
692
+ ))}
693
+ </select>
694
+ </div>
695
+ );
696
+ }
697
+
698
+ function ArrayField({
699
+ id,
700
+ label,
701
+ value,
702
+ itemType,
703
+ onChange,
704
+ disabled,
705
+ required,
706
+ }: BaseFieldProps & {
707
+ value: unknown[] | undefined;
708
+ itemType?: string;
709
+ onChange: (value: unknown[]) => void;
710
+ }): React.ReactElement {
711
+ const [inputValue, setInputValue] = useState("");
712
+ const items = Array.isArray(value) ? value : [];
713
+
714
+ const handleAdd = () => {
715
+ if (!inputValue.trim()) return;
716
+ let newItem: unknown = inputValue.trim();
717
+ if (itemType === "number") {
718
+ newItem = Number(newItem);
719
+ }
720
+ onChange([...items, newItem]);
721
+ setInputValue("");
722
+ };
723
+
724
+ const handleRemove = (index: number) => {
725
+ onChange(items.filter((_, i) => i !== index));
726
+ };
727
+
728
+ const handleKeyDown = (e: React.KeyboardEvent) => {
729
+ if (e.key === "Enter" || e.key === ",") {
730
+ e.preventDefault();
731
+ handleAdd();
732
+ }
733
+ };
734
+
735
+ return (
736
+ <div className="wn-frontmatter-field">
737
+ <label htmlFor={id} className="wn-frontmatter-label">
738
+ {label}
739
+ {required && <span className="wn-frontmatter-required">*</span>}
740
+ </label>
741
+ {items.length > 0 && (
742
+ <div className="wn-frontmatter-tags">
743
+ {items.map((item, index) => (
744
+ <span key={index} className="wn-frontmatter-tag">
745
+ {String(item)}
746
+ <button
747
+ type="button"
748
+ onClick={() => handleRemove(index)}
749
+ disabled={disabled}
750
+ className="wn-frontmatter-tag-remove"
751
+ >
752
+ <X size={10} />
753
+ </button>
754
+ </span>
755
+ ))}
756
+ </div>
757
+ )}
758
+ <input
759
+ id={id}
760
+ type={itemType === "number" ? "number" : "text"}
761
+ value={inputValue}
762
+ onChange={(e) => setInputValue(e.target.value)}
763
+ onKeyDown={handleKeyDown}
764
+ onBlur={handleAdd}
765
+ disabled={disabled}
766
+ placeholder="Type and press Enter"
767
+ className="wn-frontmatter-input"
768
+ />
769
+ </div>
770
+ );
771
+ }
772
+
773
+ function ImageField({
774
+ id,
775
+ label,
776
+ value,
777
+ onChange,
778
+ disabled,
779
+ required,
780
+ onUpload,
781
+ collection,
782
+ contentId,
783
+ }: BaseFieldProps & {
784
+ value: string | undefined;
785
+ onChange: (value: string | undefined) => void;
786
+ onUpload?: (file: File) => Promise<string | null>;
787
+ collection?: string;
788
+ contentId?: string;
789
+ }): React.ReactElement {
790
+ const [uploading, setUploading] = useState(false);
791
+ const [previewError, setPreviewError] = useState(false);
792
+
793
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
794
+ const file = e.target.files?.[0];
795
+ if (!file || !onUpload) return;
796
+
797
+ setUploading(true);
798
+ setPreviewError(false);
799
+ try {
800
+ const path = await onUpload(file);
801
+ if (path) {
802
+ onChange(path);
803
+ }
804
+ } finally {
805
+ setUploading(false);
806
+ }
807
+ };
808
+
809
+ const handleValueChange = (newValue: string) => {
810
+ setPreviewError(false);
811
+ onChange(newValue || undefined);
812
+ };
813
+
814
+ // Build preview URL from relative path
815
+ // URL format: /_writenex/api/images/:collection/:contentId/:imagePath
816
+ const getPreviewUrl = (): string | null => {
817
+ if (!value || !collection || !contentId) return null;
818
+
819
+ // Remove leading ./ from path if present
820
+ const imagePath = value.replace(/^\.\//, "");
821
+ return `/_writenex/api/images/${collection}/${contentId}/${imagePath}`;
822
+ };
823
+
824
+ const previewUrl = getPreviewUrl();
825
+ const showPreview = previewUrl && !previewError;
826
+
827
+ return (
828
+ <div className="wn-frontmatter-field">
829
+ <label htmlFor={id} className="wn-frontmatter-label">
830
+ {label}
831
+ {required && <span className="wn-frontmatter-required">*</span>}
832
+ </label>
833
+
834
+ {/* Image Preview */}
835
+ {showPreview && (
836
+ <div className="wn-frontmatter-image-preview">
837
+ <img
838
+ src={previewUrl}
839
+ alt={`Preview for ${label}`}
840
+ onError={() => setPreviewError(true)}
841
+ />
842
+ </div>
843
+ )}
844
+
845
+ <div className="wn-frontmatter-image-field">
846
+ <input
847
+ id={id}
848
+ type="text"
849
+ value={value ?? ""}
850
+ onChange={(e) => handleValueChange(e.target.value)}
851
+ disabled={disabled}
852
+ placeholder="./images/hero.jpg"
853
+ className="wn-frontmatter-input"
854
+ />
855
+ {onUpload && (
856
+ <label className="wn-frontmatter-upload-btn">
857
+ <input
858
+ type="file"
859
+ accept="image/*"
860
+ onChange={handleFileChange}
861
+ disabled={disabled || uploading}
862
+ style={{ display: "none" }}
863
+ />
864
+ {uploading ? "..." : "Upload"}
865
+ </label>
866
+ )}
867
+ </div>
868
+ </div>
869
+ );
870
+ }
871
+
872
+ // Utilities
873
+
874
+ function formatFieldLabel(name: string): string {
875
+ return name
876
+ .replace(/([A-Z])/g, " $1")
877
+ .replace(/[_-]/g, " ")
878
+ .replace(/^\w/, (c) => c.toUpperCase())
879
+ .trim();
880
+ }
881
+
882
+ function formatDateForInput(value: unknown): string {
883
+ if (!value) return "";
884
+
885
+ if (typeof value === "string") {
886
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
887
+ return value;
888
+ }
889
+ try {
890
+ const date = new Date(value);
891
+ if (!isNaN(date.getTime())) {
892
+ return date.toISOString().split("T")[0] ?? "";
893
+ }
894
+ } catch {
895
+ return "";
896
+ }
897
+ }
898
+
899
+ if (value instanceof Date) {
900
+ return value.toISOString().split("T")[0] ?? "";
901
+ }
902
+
903
+ return "";
904
+ }
905
+
906
+ function parseEnumFromDescription(description?: string): string[] {
907
+ if (!description) return [];
908
+ const match = description.match(/^Options:\s*(.+)$/i);
909
+ if (!match || !match[1]) return [];
910
+ return match[1]
911
+ .split(",")
912
+ .map((s) => s.trim())
913
+ .filter(Boolean);
914
+ }