@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,900 @@
1
+ /**
2
+ * @fileoverview Main App component for Writenex editor
3
+ *
4
+ * This is the root component for the Writenex editor UI.
5
+ * It provides the main layout with sidebar navigation and editor.
6
+ *
7
+ * @module @writenex/astro/client/App
8
+ */
9
+
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { Sidebar } from "./components/Sidebar";
12
+ import {
13
+ LazyEditor as Editor,
14
+ EditorEmpty,
15
+ EditorLoading,
16
+ } from "./components/LazyEditor";
17
+ import { ConfigPanel } from "./components/ConfigPanel/ConfigPanel";
18
+ import { CreateContentModal } from "./components/CreateContentModal";
19
+ import { SelectCollectionModal } from "./components/SelectCollectionModal";
20
+ import { UnsavedChangesModal } from "./components/UnsavedChangesModal";
21
+ import { Header } from "./components/Header";
22
+ import { FrontmatterForm } from "./components/FrontmatterForm";
23
+ import { Save, FileEdit, CheckCircle, ExternalLink } from "lucide-react";
24
+ import type { CollectionSchema } from "../types";
25
+ import {
26
+ useCollections,
27
+ useContentList,
28
+ useConfig,
29
+ type ContentItem,
30
+ } from "./hooks/useApi";
31
+ import { useSharedApi } from "./context/ApiContext";
32
+ import {
33
+ useAutosave,
34
+ formatLastSaved,
35
+ type AutosaveStatus,
36
+ } from "./hooks/useAutosave";
37
+ import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
38
+ import { ShortcutsHelpModal } from "./components/KeyboardShortcuts";
39
+ import { SearchReplacePanel } from "./components/SearchReplace";
40
+ import { useSearch } from "./hooks/useSearch";
41
+ import { VersionHistoryPanel } from "./components/VersionHistory";
42
+ import { SkipLink } from "./components/SkipLink";
43
+ import { LiveRegion } from "./components/LiveRegion";
44
+ import { useAnnounce } from "./hooks/useAnnounce";
45
+
46
+ function generatePreviewUrl(
47
+ pattern: string,
48
+ contentId: string,
49
+ frontmatter: Record<string, unknown>,
50
+ trailingSlash: "always" | "never" | "ignore" = "ignore"
51
+ ): string {
52
+ let url = pattern;
53
+ url = url.replace("{slug}", contentId);
54
+ const tokens = pattern.match(/\{([^}]+)\}/g) ?? [];
55
+ for (const token of tokens) {
56
+ const key = token.slice(1, -1);
57
+ if (key !== "slug" && frontmatter[key] !== undefined) {
58
+ url = url.replace(token, String(frontmatter[key]));
59
+ }
60
+ }
61
+
62
+ // Apply trailingSlash setting
63
+ if (trailingSlash === "always" && !url.endsWith("/")) {
64
+ url = url + "/";
65
+ } else if (trailingSlash === "never" && url.endsWith("/") && url !== "/") {
66
+ url = url.slice(0, -1);
67
+ }
68
+
69
+ return url;
70
+ }
71
+
72
+ function AutosaveIndicator({
73
+ status,
74
+ hasUnsavedChanges,
75
+ lastSaved,
76
+ enabled,
77
+ onToggle,
78
+ announce,
79
+ }: {
80
+ status: AutosaveStatus;
81
+ hasUnsavedChanges: boolean;
82
+ lastSaved: Date | null;
83
+ enabled: boolean;
84
+ onToggle: () => void;
85
+ announce: (message: string, politeness?: "polite" | "assertive") => void;
86
+ }): React.ReactElement {
87
+ const prevStatusRef = useRef<AutosaveStatus | null>(null);
88
+
89
+ let text = "";
90
+ let statusClass = "wn-autosave-text--idle";
91
+
92
+ if (!enabled) {
93
+ text = hasUnsavedChanges ? "Unsaved" : "Autosave off";
94
+ statusClass = "wn-autosave-text--pending";
95
+ } else {
96
+ switch (status) {
97
+ case "saving":
98
+ text = "Saving...";
99
+ statusClass = "wn-autosave-text--saving";
100
+ break;
101
+ case "saved":
102
+ text = "Saved";
103
+ statusClass = "wn-autosave-text--saved";
104
+ break;
105
+ case "error":
106
+ text = "Save failed";
107
+ statusClass = "wn-autosave-text--error";
108
+ break;
109
+ case "pending":
110
+ text = "Unsaved";
111
+ statusClass = "wn-autosave-text--pending";
112
+ break;
113
+ default:
114
+ text = lastSaved ? formatLastSaved(lastSaved) : "";
115
+ statusClass = "wn-autosave-text--idle";
116
+ }
117
+ }
118
+
119
+ // Announce status changes to screen readers (Requirements 3.1, 3.2, 3.3, 3.4)
120
+ useEffect(() => {
121
+ // Only announce when status actually changes
122
+ if (prevStatusRef.current === status) return;
123
+ prevStatusRef.current = status;
124
+
125
+ if (!enabled) return;
126
+
127
+ switch (status) {
128
+ case "saved":
129
+ announce("Content saved", "polite");
130
+ break;
131
+ case "error":
132
+ announce("Save failed", "assertive");
133
+ break;
134
+ case "pending":
135
+ announce("Unsaved changes", "polite");
136
+ break;
137
+ }
138
+ }, [status, enabled, announce]);
139
+
140
+ return (
141
+ <button
142
+ className="wn-autosave-indicator"
143
+ onClick={onToggle}
144
+ title={enabled ? "Disable autosave" : "Enable autosave"}
145
+ >
146
+ <Save
147
+ size={14}
148
+ style={{
149
+ color: enabled ? "var(--wn-emerald-500)" : "var(--wn-zinc-500)",
150
+ opacity: enabled ? 1 : 0.5,
151
+ }}
152
+ />
153
+ {text && (
154
+ <span className={`wn-autosave-text ${statusClass}`}>{text}</span>
155
+ )}
156
+ </button>
157
+ );
158
+ }
159
+
160
+ /** Main content area ID for skip link navigation */
161
+ const MAIN_CONTENT_ID = "wn-main-editor";
162
+
163
+ export function App(): React.ReactElement {
164
+ const api = useSharedApi();
165
+ const { config, refresh: refreshConfig } = useConfig(api);
166
+
167
+ // Accessibility: Live region for screen reader announcements
168
+ const { announce, currentMessage, currentPoliteness } = useAnnounce();
169
+
170
+ const {
171
+ collections,
172
+ loading: collectionsLoading,
173
+ refresh: refreshCollections,
174
+ } = useCollections(api);
175
+
176
+ const [selectedCollection, setSelectedCollection] = useState<string | null>(
177
+ null
178
+ );
179
+ const [selectedContentId, setSelectedContentId] = useState<string | null>(
180
+ null
181
+ );
182
+
183
+ const {
184
+ items: contentItems,
185
+ loading: contentLoading,
186
+ refresh: refreshContent,
187
+ } = useContentList(api, selectedCollection);
188
+
189
+ const [currentContent, setCurrentContent] = useState<ContentItem | null>(
190
+ null
191
+ );
192
+ const [contentLoadingState, setContentLoadingState] = useState(false);
193
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
194
+ const [saving, setSaving] = useState(false);
195
+ const [autosaveEnabled, setAutosaveEnabled] = useState<boolean | null>(null);
196
+ const [showConfigPanel, setShowConfigPanel] = useState(false);
197
+ const [showCreateModal, setShowCreateModal] = useState(false);
198
+ const [isCreatingContent, setIsCreatingContent] = useState(false);
199
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true);
200
+ const [isFrontmatterOpen, setIsFrontmatterOpen] = useState(true);
201
+ const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
202
+
203
+ // Unsaved changes modal state
204
+ const [showUnsavedModal, setShowUnsavedModal] = useState(false);
205
+ const [pendingContentId, setPendingContentId] = useState<string | null>(null);
206
+ const [isSavingBeforeSwitch, setIsSavingBeforeSwitch] = useState(false);
207
+
208
+ // Select collection modal state (for Alt+N without selected collection)
209
+ const [showSelectCollectionModal, setShowSelectCollectionModal] =
210
+ useState(false);
211
+
212
+ const contentRef = useRef<ContentItem | null>(null);
213
+ contentRef.current = currentContent;
214
+
215
+ // Search functionality
216
+ const getContent = useCallback(
217
+ () => currentContent?.body ?? "",
218
+ [currentContent?.body]
219
+ );
220
+ const {
221
+ isSearchOpen,
222
+ toggleSearch,
223
+ closeSearch,
224
+ searchQuery,
225
+ searchActiveIndex,
226
+ totalMatches,
227
+ handleFind,
228
+ handleNextMatch,
229
+ handlePreviousMatch,
230
+ handleReplace,
231
+ handleReplaceAll,
232
+ } = useSearch(getContent);
233
+
234
+ const currentCollection = collections.find(
235
+ (c) => c.name === selectedCollection
236
+ );
237
+ const currentSchema = currentCollection?.schema as
238
+ | CollectionSchema
239
+ | undefined;
240
+
241
+ useEffect(() => {
242
+ refreshConfig();
243
+ }, [refreshConfig]);
244
+
245
+ // Sync autosave state with config when config is loaded
246
+ useEffect(() => {
247
+ if (config && autosaveEnabled === null) {
248
+ setAutosaveEnabled(config.editor?.autosave !== false);
249
+ }
250
+ }, [config, autosaveEnabled]);
251
+
252
+ useEffect(() => {
253
+ if (selectedCollection && selectedContentId) {
254
+ setContentLoadingState(true);
255
+ announce("Loading content", "polite");
256
+ api
257
+ .getContent(selectedCollection, selectedContentId)
258
+ .then((content) => {
259
+ setCurrentContent(content);
260
+ setHasUnsavedChanges(false);
261
+ announce("Content loaded", "polite");
262
+ })
263
+ .catch((err) => {
264
+ console.error("Failed to load content:", err);
265
+ setCurrentContent(null);
266
+ announce("Failed to load content", "assertive");
267
+ })
268
+ .finally(() => {
269
+ setContentLoadingState(false);
270
+ });
271
+ } else {
272
+ setCurrentContent(null);
273
+ }
274
+ }, [api, selectedCollection, selectedContentId, announce]);
275
+
276
+ const handleSelectCollection = useCallback((name: string) => {
277
+ setSelectedCollection(name);
278
+ setSelectedContentId(null);
279
+ setCurrentContent(null);
280
+ }, []);
281
+
282
+ const handleSelectContent = useCallback(
283
+ (id: string) => {
284
+ if (hasUnsavedChanges) {
285
+ setPendingContentId(id);
286
+ setShowUnsavedModal(true);
287
+ return;
288
+ }
289
+ setSelectedContentId(id);
290
+ },
291
+ [hasUnsavedChanges]
292
+ );
293
+
294
+ const handleUnsavedModalClose = useCallback(() => {
295
+ setShowUnsavedModal(false);
296
+ setPendingContentId(null);
297
+ }, []);
298
+
299
+ const handleUnsavedDiscard = useCallback(() => {
300
+ setShowUnsavedModal(false);
301
+ setHasUnsavedChanges(false);
302
+ if (pendingContentId) {
303
+ setSelectedContentId(pendingContentId);
304
+ setPendingContentId(null);
305
+ }
306
+ }, [pendingContentId]);
307
+
308
+ const [contentChanged, setContentChanged] = useState(false);
309
+
310
+ const handleContentChange = useCallback(
311
+ (markdown: string) => {
312
+ if (currentContent && markdown !== currentContent.body) {
313
+ setHasUnsavedChanges(true);
314
+ setContentChanged(true);
315
+ setCurrentContent((prev) =>
316
+ prev ? { ...prev, body: markdown } : null
317
+ );
318
+ }
319
+ },
320
+ [currentContent]
321
+ );
322
+
323
+ const handleFrontmatterChange = useCallback(
324
+ (frontmatter: Record<string, unknown>) => {
325
+ setHasUnsavedChanges(true);
326
+ setContentChanged(true);
327
+ setCurrentContent((prev) => (prev ? { ...prev, frontmatter } : null));
328
+ },
329
+ []
330
+ );
331
+
332
+ const handleImageUpload = useCallback(
333
+ async (file: File, _fieldName: string): Promise<string | null> => {
334
+ if (!selectedCollection || !selectedContentId) return null;
335
+ try {
336
+ const result = await api.uploadImage(
337
+ file,
338
+ selectedCollection,
339
+ selectedContentId
340
+ );
341
+ if (result.success && result.path) {
342
+ return result.path;
343
+ }
344
+ alert(`Failed to upload image: ${result.error}`);
345
+ return null;
346
+ } catch (err) {
347
+ alert(
348
+ `Failed to upload image: ${err instanceof Error ? err.message : "Unknown error"}`
349
+ );
350
+ return null;
351
+ }
352
+ },
353
+ [api, selectedCollection, selectedContentId]
354
+ );
355
+
356
+ const performSave = useCallback(async (): Promise<boolean> => {
357
+ const content = contentRef.current;
358
+ if (!selectedCollection || !selectedContentId || !content) return false;
359
+
360
+ setSaving(true);
361
+ try {
362
+ const result = await api.updateContent(
363
+ selectedCollection,
364
+ selectedContentId,
365
+ {
366
+ frontmatter: content.frontmatter,
367
+ body: content.body,
368
+ }
369
+ );
370
+
371
+ if (result.success) {
372
+ setHasUnsavedChanges(false);
373
+ return true;
374
+ } else {
375
+ console.error("Failed to save:", result.error);
376
+ return false;
377
+ }
378
+ } catch (err) {
379
+ console.error("Failed to save:", err);
380
+ return false;
381
+ } finally {
382
+ setSaving(false);
383
+ }
384
+ }, [api, selectedCollection, selectedContentId]);
385
+
386
+ const {
387
+ status: autosaveStatus,
388
+ triggerChange: triggerAutosave,
389
+ saveNow: saveNowAutosave,
390
+ lastSaved,
391
+ } = useAutosave({
392
+ delay: config?.editor?.autosaveInterval ?? 3000,
393
+ enabled: autosaveEnabled === true && hasUnsavedChanges,
394
+ onSave: performSave,
395
+ onError: (err) => {
396
+ console.error("Autosave failed:", err);
397
+ },
398
+ });
399
+
400
+ useEffect(() => {
401
+ if (contentChanged && autosaveEnabled === true) {
402
+ triggerAutosave();
403
+ setContentChanged(false);
404
+ }
405
+ }, [contentChanged, autosaveEnabled, triggerAutosave]);
406
+
407
+ const handleSave = useCallback(async () => {
408
+ if (!hasUnsavedChanges) return;
409
+ await saveNowAutosave();
410
+ }, [hasUnsavedChanges, saveNowAutosave]);
411
+
412
+ const handleUnsavedSaveAndContinue = useCallback(async () => {
413
+ setIsSavingBeforeSwitch(true);
414
+ try {
415
+ await saveNowAutosave();
416
+ setShowUnsavedModal(false);
417
+ if (pendingContentId) {
418
+ setSelectedContentId(pendingContentId);
419
+ setPendingContentId(null);
420
+ }
421
+ } finally {
422
+ setIsSavingBeforeSwitch(false);
423
+ }
424
+ }, [pendingContentId, saveNowAutosave]);
425
+
426
+ const handleOpenCreateModal = useCallback(() => {
427
+ if (!selectedCollection) return;
428
+ setShowCreateModal(true);
429
+ }, [selectedCollection]);
430
+
431
+ // Handler for Alt+N shortcut - shows collection selector if no collection selected
432
+ const handleNewContentShortcut = useCallback(() => {
433
+ if (selectedCollection) {
434
+ setShowCreateModal(true);
435
+ } else {
436
+ setShowSelectCollectionModal(true);
437
+ }
438
+ }, [selectedCollection]);
439
+
440
+ // Handler when collection is selected from SelectCollectionModal
441
+ const handleSelectCollectionForCreate = useCallback(
442
+ (collectionName: string) => {
443
+ setShowSelectCollectionModal(false);
444
+ setSelectedCollection(collectionName);
445
+ // Small delay to ensure state is updated before opening create modal
446
+ setTimeout(() => setShowCreateModal(true), 50);
447
+ },
448
+ []
449
+ );
450
+
451
+ const handleCreateContent = useCallback(
452
+ async (title: string) => {
453
+ if (!selectedCollection) return;
454
+
455
+ setIsCreatingContent(true);
456
+ try {
457
+ // Get the date field name from collection schema (pubDate, publishDate, or date)
458
+ const dateFieldName = currentCollection?.schema
459
+ ? Object.keys(currentCollection.schema).find((key) =>
460
+ ["pubDate", "publishDate", "date"].includes(key)
461
+ )
462
+ : undefined;
463
+
464
+ const frontmatter: Record<string, unknown> = {
465
+ title,
466
+ draft: true,
467
+ };
468
+
469
+ // Add date field if schema has one
470
+ if (dateFieldName) {
471
+ frontmatter[dateFieldName] = new Date().toISOString().split("T")[0];
472
+ }
473
+
474
+ const result = await api.createContent(selectedCollection, {
475
+ frontmatter,
476
+ body: "",
477
+ });
478
+
479
+ if (result.success && result.id) {
480
+ setShowCreateModal(false);
481
+ await refreshContent();
482
+ setSelectedContentId(result.id);
483
+ } else {
484
+ alert(`Failed to create: ${result.error}`);
485
+ }
486
+ } catch (err) {
487
+ alert(
488
+ `Failed to create: ${err instanceof Error ? err.message : "Unknown error"}`
489
+ );
490
+ } finally {
491
+ setIsCreatingContent(false);
492
+ }
493
+ },
494
+ [api, selectedCollection, currentCollection?.schema, refreshContent]
495
+ );
496
+
497
+ const handlePreview = useCallback(() => {
498
+ if (!currentCollection?.previewUrl || !selectedContentId || !currentContent)
499
+ return;
500
+
501
+ const url = generatePreviewUrl(
502
+ currentCollection.previewUrl,
503
+ selectedContentId,
504
+ currentContent.frontmatter,
505
+ config?.trailingSlash
506
+ );
507
+ window.open(url, "_blank");
508
+ }, [
509
+ currentCollection,
510
+ selectedContentId,
511
+ currentContent,
512
+ config?.trailingSlash,
513
+ ]);
514
+
515
+ const handleToggleDraft = useCallback(() => {
516
+ if (!currentContent) return;
517
+
518
+ const newDraftStatus = !currentContent.frontmatter.draft;
519
+ setHasUnsavedChanges(true);
520
+ setContentChanged(true);
521
+ setCurrentContent((prev) =>
522
+ prev
523
+ ? {
524
+ ...prev,
525
+ frontmatter: { ...prev.frontmatter, draft: newDraftStatus },
526
+ }
527
+ : null
528
+ );
529
+ }, [currentContent]);
530
+
531
+ const handleToggleVersionHistory = useCallback(() => {
532
+ setIsVersionHistoryOpen((prev) => !prev);
533
+ }, []);
534
+
535
+ const handleVersionRestore = useCallback((content: string) => {
536
+ // Parse the restored content to extract frontmatter and body
537
+ // The content is raw markdown with frontmatter
538
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
539
+ if (frontmatterMatch) {
540
+ try {
541
+ // Parse YAML frontmatter
542
+ const yamlContent = frontmatterMatch[1] ?? "";
543
+ const body = frontmatterMatch[2] ?? "";
544
+ const frontmatter: Record<string, unknown> = {};
545
+
546
+ // Simple YAML parsing for common fields
547
+ yamlContent.split("\n").forEach((line) => {
548
+ const colonIndex = line.indexOf(":");
549
+ if (colonIndex > 0) {
550
+ const key = line.slice(0, colonIndex).trim();
551
+ let value: unknown = line.slice(colonIndex + 1).trim();
552
+
553
+ // Handle quoted strings
554
+ if (
555
+ (value as string).startsWith('"') &&
556
+ (value as string).endsWith('"')
557
+ ) {
558
+ value = (value as string).slice(1, -1);
559
+ } else if (
560
+ (value as string).startsWith("'") &&
561
+ (value as string).endsWith("'")
562
+ ) {
563
+ value = (value as string).slice(1, -1);
564
+ } else if (value === "true") {
565
+ value = true;
566
+ } else if (value === "false") {
567
+ value = false;
568
+ } else if (!isNaN(Number(value)) && value !== "") {
569
+ value = Number(value);
570
+ }
571
+
572
+ frontmatter[key] = value;
573
+ }
574
+ });
575
+
576
+ setCurrentContent((prev) =>
577
+ prev ? { ...prev, frontmatter, body } : null
578
+ );
579
+ setHasUnsavedChanges(true);
580
+ setContentChanged(true);
581
+ } catch {
582
+ // If parsing fails, just update the body
583
+ setCurrentContent((prev) => (prev ? { ...prev, body: content } : null));
584
+ setHasUnsavedChanges(true);
585
+ setContentChanged(true);
586
+ }
587
+ } else {
588
+ // No frontmatter, just update body
589
+ setCurrentContent((prev) => (prev ? { ...prev, body: content } : null));
590
+ setHasUnsavedChanges(true);
591
+ setContentChanged(true);
592
+ }
593
+ }, []);
594
+
595
+ const { showHelp, toggleHelp, closeHelp, shortcuts } = useKeyboardShortcuts({
596
+ shortcuts: [
597
+ {
598
+ key: "save",
599
+ label: "Save",
600
+ keys: "s",
601
+ ctrl: true,
602
+ handler: handleSave,
603
+ enabled: hasUnsavedChanges,
604
+ },
605
+ {
606
+ key: "new",
607
+ label: "New content",
608
+ keys: "n",
609
+ alt: true,
610
+ handler: handleNewContentShortcut,
611
+ enabled: true,
612
+ },
613
+ {
614
+ key: "preview",
615
+ label: "Open preview",
616
+ keys: "p",
617
+ ctrl: true,
618
+ handler: handlePreview,
619
+ enabled: !!currentContent && !!currentCollection?.previewUrl,
620
+ },
621
+ {
622
+ key: "refresh",
623
+ label: "Refresh content",
624
+ keys: "r",
625
+ ctrl: true,
626
+ shift: true,
627
+ handler: refreshContent,
628
+ enabled: !!selectedCollection,
629
+ },
630
+ {
631
+ key: "search",
632
+ label: "Search & Replace",
633
+ keys: "f",
634
+ ctrl: true,
635
+ handler: toggleSearch,
636
+ enabled: !!currentContent,
637
+ },
638
+ {
639
+ key: "escape",
640
+ label: "Close modal",
641
+ keys: "Escape",
642
+ handler: () => {},
643
+ },
644
+ ],
645
+ });
646
+
647
+ // Search replace handlers that update content
648
+ const onSearchReplace = useCallback(
649
+ (replacement: string) => {
650
+ if (!currentContent) return;
651
+ handleReplace(replacement, currentContent.body, (newBody) => {
652
+ setHasUnsavedChanges(true);
653
+ setContentChanged(true);
654
+ setCurrentContent((prev) => (prev ? { ...prev, body: newBody } : null));
655
+ });
656
+ },
657
+ [currentContent, handleReplace]
658
+ );
659
+
660
+ const onSearchReplaceAll = useCallback(
661
+ (replacement: string): number => {
662
+ if (!currentContent) return 0;
663
+ let count = 0;
664
+ handleReplaceAll(replacement, currentContent.body, (newBody) => {
665
+ setHasUnsavedChanges(true);
666
+ setContentChanged(true);
667
+ setCurrentContent((prev) => (prev ? { ...prev, body: newBody } : null));
668
+ count = totalMatches;
669
+ });
670
+ return count;
671
+ },
672
+ [currentContent, handleReplaceAll, totalMatches]
673
+ );
674
+
675
+ return (
676
+ <div className="wn-app">
677
+ {/* Skip link for keyboard navigation - must be first focusable element */}
678
+ <SkipLink targetId={MAIN_CONTENT_ID}>Skip to main content</SkipLink>
679
+
680
+ {/* Global live region for screen reader announcements */}
681
+ <LiveRegion message={currentMessage} politeness={currentPoliteness} />
682
+
683
+ {showHelp && (
684
+ <ShortcutsHelpModal shortcuts={shortcuts} onClose={closeHelp} />
685
+ )}
686
+
687
+ <CreateContentModal
688
+ isOpen={showCreateModal}
689
+ onClose={() => setShowCreateModal(false)}
690
+ onCreate={handleCreateContent}
691
+ collectionName={selectedCollection ?? ""}
692
+ isCreating={isCreatingContent}
693
+ />
694
+
695
+ <UnsavedChangesModal
696
+ isOpen={showUnsavedModal}
697
+ onClose={handleUnsavedModalClose}
698
+ onDiscard={handleUnsavedDiscard}
699
+ onSave={handleUnsavedSaveAndContinue}
700
+ isSaving={isSavingBeforeSwitch}
701
+ />
702
+
703
+ <SelectCollectionModal
704
+ isOpen={showSelectCollectionModal}
705
+ onClose={() => setShowSelectCollectionModal(false)}
706
+ onSelect={handleSelectCollectionForCreate}
707
+ collections={collections}
708
+ isLoading={collectionsLoading}
709
+ />
710
+
711
+ <ConfigPanel
712
+ config={config}
713
+ collections={collections}
714
+ isOpen={showConfigPanel}
715
+ onClose={() => setShowConfigPanel(false)}
716
+ />
717
+
718
+ {/* Main Header with Logo and Toolbar */}
719
+ <Header
720
+ isSidebarOpen={isSidebarOpen}
721
+ onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
722
+ isFrontmatterOpen={isFrontmatterOpen}
723
+ onToggleFrontmatter={() => setIsFrontmatterOpen(!isFrontmatterOpen)}
724
+ isSearchOpen={isSearchOpen}
725
+ onToggleSearch={toggleSearch}
726
+ isVersionHistoryOpen={isVersionHistoryOpen}
727
+ onToggleVersionHistory={handleToggleVersionHistory}
728
+ versionHistoryEnabled={!!currentContent}
729
+ onKeyboardShortcuts={toggleHelp}
730
+ onSettings={() => setShowConfigPanel(true)}
731
+ onNewContent={handleNewContentShortcut}
732
+ />
733
+
734
+ {/* Secondary Header - Content Actions Bar */}
735
+ {currentContent && (
736
+ <div className="wn-content-bar">
737
+ {/* Left: Content title */}
738
+ <div className="wn-content-bar-left">
739
+ <span
740
+ className="wn-content-bar-title"
741
+ title={String(
742
+ currentContent.frontmatter.title ?? currentContent.id
743
+ )}
744
+ >
745
+ {String(currentContent.frontmatter.title ?? currentContent.id)}
746
+ </span>
747
+ </div>
748
+
749
+ {/* Right: Actions */}
750
+ <div className="wn-content-bar-right">
751
+ <AutosaveIndicator
752
+ status={autosaveStatus}
753
+ hasUnsavedChanges={hasUnsavedChanges}
754
+ lastSaved={lastSaved}
755
+ enabled={autosaveEnabled === true}
756
+ onToggle={() => setAutosaveEnabled((prev) => prev !== true)}
757
+ announce={announce}
758
+ />
759
+ <div className="wn-content-bar-separator" aria-hidden="true" />
760
+ <button
761
+ className={`wn-btn-secondary ${
762
+ currentContent.frontmatter.draft
763
+ ? "wn-btn-draft"
764
+ : "wn-btn-published"
765
+ }`}
766
+ onClick={handleToggleDraft}
767
+ title={
768
+ currentContent.frontmatter.draft
769
+ ? "Publish this content"
770
+ : "Set as draft"
771
+ }
772
+ >
773
+ {currentContent.frontmatter.draft ? (
774
+ <>
775
+ <FileEdit size={14} /> Draft
776
+ </>
777
+ ) : (
778
+ <>
779
+ <CheckCircle size={14} /> Published
780
+ </>
781
+ )}
782
+ </button>
783
+ {currentCollection?.previewUrl &&
784
+ selectedContentId &&
785
+ !currentContent.frontmatter.draft && (
786
+ <a
787
+ href={generatePreviewUrl(
788
+ currentCollection.previewUrl,
789
+ selectedContentId,
790
+ currentContent.frontmatter,
791
+ config?.trailingSlash
792
+ )}
793
+ target="_blank"
794
+ rel="noopener noreferrer"
795
+ className="wn-btn-secondary wn-btn-preview"
796
+ title="Preview in new tab"
797
+ >
798
+ <ExternalLink size={14} />
799
+ Preview
800
+ </a>
801
+ )}
802
+ <div className="wn-content-bar-separator" aria-hidden="true" />
803
+ <button
804
+ className="wn-btn-primary"
805
+ onClick={handleSave}
806
+ disabled={!hasUnsavedChanges || saving}
807
+ >
808
+ <Save size={14} />
809
+ {saving ? "Saving..." : "Save"}
810
+ </button>
811
+ </div>
812
+ </div>
813
+ )}
814
+
815
+ {/* Main layout */}
816
+ <div className="wn-main-layout">
817
+ {/* Left: Sidebar */}
818
+ <Sidebar
819
+ isOpen={isSidebarOpen}
820
+ onClose={() => setIsSidebarOpen(false)}
821
+ collections={collections}
822
+ collectionsLoading={collectionsLoading}
823
+ selectedCollection={selectedCollection}
824
+ onSelectCollection={handleSelectCollection}
825
+ contentItems={contentItems}
826
+ contentLoading={contentLoading}
827
+ selectedContent={selectedContentId}
828
+ onSelectContent={handleSelectContent}
829
+ onCreateContent={handleOpenCreateModal}
830
+ onRefreshCollections={refreshCollections}
831
+ onRefreshContent={refreshContent}
832
+ />
833
+
834
+ {/* Center: Editor */}
835
+ <main
836
+ id={MAIN_CONTENT_ID}
837
+ className="wn-main-content"
838
+ style={{ position: "relative" }}
839
+ aria-label="Content editor"
840
+ aria-busy={contentLoadingState}
841
+ >
842
+ {/* Search Panel - rendered outside editor wrapper for proper positioning */}
843
+ {currentContent && (
844
+ <SearchReplacePanel
845
+ isOpen={isSearchOpen}
846
+ onClose={closeSearch}
847
+ onSearch={handleFind}
848
+ onNextMatch={handleNextMatch}
849
+ onPreviousMatch={handlePreviousMatch}
850
+ onReplace={onSearchReplace}
851
+ onReplaceAll={onSearchReplaceAll}
852
+ currentMatch={searchActiveIndex}
853
+ totalMatches={totalMatches}
854
+ />
855
+ )}
856
+ {contentLoadingState ? (
857
+ <EditorLoading />
858
+ ) : currentContent ? (
859
+ <div className="wn-editor-wrapper">
860
+ <Editor
861
+ initialContent={currentContent.body}
862
+ onChange={handleContentChange}
863
+ onImageUpload={(file) => handleImageUpload(file, "body")}
864
+ basePath={api.basePath}
865
+ collection={selectedCollection ?? undefined}
866
+ contentId={selectedContentId ?? undefined}
867
+ searchQuery={searchQuery}
868
+ searchActiveIndex={searchActiveIndex}
869
+ />
870
+ </div>
871
+ ) : (
872
+ <EditorEmpty onNewContent={handleNewContentShortcut} />
873
+ )}
874
+ </main>
875
+
876
+ {/* Right: Frontmatter Panel */}
877
+ <FrontmatterForm
878
+ isOpen={isFrontmatterOpen}
879
+ onClose={() => setIsFrontmatterOpen(false)}
880
+ frontmatter={currentContent?.frontmatter ?? null}
881
+ schema={currentSchema}
882
+ onChange={handleFrontmatterChange}
883
+ onImageUpload={handleImageUpload}
884
+ collection={selectedCollection ?? undefined}
885
+ contentId={selectedContentId ?? undefined}
886
+ />
887
+
888
+ {/* Version History Panel */}
889
+ <VersionHistoryPanel
890
+ isOpen={isVersionHistoryOpen}
891
+ onClose={() => setIsVersionHistoryOpen(false)}
892
+ collection={selectedCollection}
893
+ contentId={selectedContentId}
894
+ currentContent={currentContent?.body ?? ""}
895
+ onRestore={handleVersionRestore}
896
+ />
897
+ </div>
898
+ </div>
899
+ );
900
+ }