@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.
- package/README.md +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- 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
|
+
}
|