@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,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sidebar component for collection and content navigation
|
|
3
|
+
*
|
|
4
|
+
* This component provides a collapsible sidebar panel for navigating
|
|
5
|
+
* collections and content items. Similar to TocPanel in Writenex Editor.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Arrow key navigation for collections and content lists
|
|
9
|
+
* - ARIA tab pattern for filter tabs
|
|
10
|
+
* - Screen reader announcements for search results
|
|
11
|
+
* - Proper aria-current for selected items
|
|
12
|
+
*
|
|
13
|
+
* @module @writenex/astro/client/components/Sidebar
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
17
|
+
import {
|
|
18
|
+
X,
|
|
19
|
+
FileEdit,
|
|
20
|
+
Folder,
|
|
21
|
+
Plus,
|
|
22
|
+
CheckCircle,
|
|
23
|
+
RefreshCw,
|
|
24
|
+
Search,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
import type { Collection, ContentSummary } from "../../hooks/useApi";
|
|
27
|
+
import { useArrowNavigation } from "../../hooks/useArrowNavigation";
|
|
28
|
+
import { useAnnounce } from "../../hooks/useAnnounce";
|
|
29
|
+
import "./Sidebar.css";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props for CollectionItem component
|
|
33
|
+
*/
|
|
34
|
+
interface CollectionItemProps {
|
|
35
|
+
collection: Collection;
|
|
36
|
+
isSelected: boolean;
|
|
37
|
+
isFocused: boolean;
|
|
38
|
+
onSelect: (name: string) => void;
|
|
39
|
+
id: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Individual collection item in the sidebar
|
|
44
|
+
*/
|
|
45
|
+
const CollectionItem = memo(function CollectionItem({
|
|
46
|
+
collection,
|
|
47
|
+
isSelected,
|
|
48
|
+
isFocused,
|
|
49
|
+
onSelect,
|
|
50
|
+
id,
|
|
51
|
+
}: CollectionItemProps) {
|
|
52
|
+
const handleClick = useCallback(() => {
|
|
53
|
+
onSelect(collection.name);
|
|
54
|
+
}, [collection.name, onSelect]);
|
|
55
|
+
|
|
56
|
+
const className = [
|
|
57
|
+
"wn-collection-item",
|
|
58
|
+
isSelected ? "wn-collection-item--selected" : "",
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" ");
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<li role="option" aria-selected={isFocused} id={id}>
|
|
65
|
+
<button
|
|
66
|
+
className={className}
|
|
67
|
+
onClick={handleClick}
|
|
68
|
+
tabIndex={isFocused ? 0 : -1}
|
|
69
|
+
aria-current={isSelected ? "true" : undefined}
|
|
70
|
+
title={collection.name}
|
|
71
|
+
>
|
|
72
|
+
<Folder size={16} />
|
|
73
|
+
<span className="wn-collection-item-name">{collection.name}</span>
|
|
74
|
+
<span className="wn-collection-item-count">{collection.count}</span>
|
|
75
|
+
</button>
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Props for ContentListItem component
|
|
82
|
+
*/
|
|
83
|
+
interface ContentItemProps {
|
|
84
|
+
item: ContentSummary;
|
|
85
|
+
isSelected: boolean;
|
|
86
|
+
isFocused: boolean;
|
|
87
|
+
onSelect: (id: string) => void;
|
|
88
|
+
id: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Individual content item in the sidebar
|
|
93
|
+
*/
|
|
94
|
+
const ContentListItem = memo(function ContentListItem({
|
|
95
|
+
item,
|
|
96
|
+
isSelected,
|
|
97
|
+
isFocused,
|
|
98
|
+
onSelect,
|
|
99
|
+
id,
|
|
100
|
+
}: ContentItemProps) {
|
|
101
|
+
const handleClick = useCallback(() => {
|
|
102
|
+
onSelect(item.id);
|
|
103
|
+
}, [item.id, onSelect]);
|
|
104
|
+
|
|
105
|
+
const className = [
|
|
106
|
+
"wn-content-item",
|
|
107
|
+
isSelected ? "wn-content-item--selected" : "",
|
|
108
|
+
]
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join(" ");
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<li role="option" aria-selected={isFocused} id={id}>
|
|
114
|
+
<button
|
|
115
|
+
className={className}
|
|
116
|
+
onClick={handleClick}
|
|
117
|
+
tabIndex={isFocused ? 0 : -1}
|
|
118
|
+
aria-current={isSelected ? "true" : undefined}
|
|
119
|
+
title={item.title}
|
|
120
|
+
>
|
|
121
|
+
<div className="wn-content-item-header">
|
|
122
|
+
<span className="wn-content-item-title">{item.title}</span>
|
|
123
|
+
{item.draft && <span className="wn-badge-draft">Draft</span>}
|
|
124
|
+
</div>
|
|
125
|
+
{item.pubDate && (
|
|
126
|
+
<span className="wn-content-item-date">
|
|
127
|
+
{formatDate(item.pubDate)}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</button>
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Format date string to readable format
|
|
137
|
+
*/
|
|
138
|
+
function formatDate(dateStr: string): string {
|
|
139
|
+
try {
|
|
140
|
+
const date = new Date(dateStr);
|
|
141
|
+
return date.toLocaleDateString("en-US", {
|
|
142
|
+
year: "numeric",
|
|
143
|
+
month: "short",
|
|
144
|
+
day: "numeric",
|
|
145
|
+
});
|
|
146
|
+
} catch {
|
|
147
|
+
return dateStr;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Props for Sidebar component
|
|
153
|
+
*/
|
|
154
|
+
interface SidebarProps {
|
|
155
|
+
/** Whether the sidebar is open */
|
|
156
|
+
isOpen: boolean;
|
|
157
|
+
/** Callback to close the sidebar */
|
|
158
|
+
onClose: () => void;
|
|
159
|
+
/** List of collections */
|
|
160
|
+
collections: Collection[];
|
|
161
|
+
/** Whether collections are loading */
|
|
162
|
+
collectionsLoading: boolean;
|
|
163
|
+
/** Currently selected collection name */
|
|
164
|
+
selectedCollection: string | null;
|
|
165
|
+
/** Callback when a collection is selected */
|
|
166
|
+
onSelectCollection: (name: string) => void;
|
|
167
|
+
/** List of content items in selected collection */
|
|
168
|
+
contentItems: ContentSummary[];
|
|
169
|
+
/** Whether content is loading */
|
|
170
|
+
contentLoading: boolean;
|
|
171
|
+
/** Currently selected content ID */
|
|
172
|
+
selectedContent: string | null;
|
|
173
|
+
/** Callback when content is selected */
|
|
174
|
+
onSelectContent: (id: string) => void;
|
|
175
|
+
/** Callback to create new content */
|
|
176
|
+
onCreateContent: () => void;
|
|
177
|
+
/** Callback to refresh collections */
|
|
178
|
+
onRefreshCollections: () => void;
|
|
179
|
+
/** Callback to refresh content */
|
|
180
|
+
onRefreshContent: () => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Collapsible sidebar panel for collection and content navigation.
|
|
185
|
+
*
|
|
186
|
+
* @component
|
|
187
|
+
*/
|
|
188
|
+
export function Sidebar({
|
|
189
|
+
isOpen,
|
|
190
|
+
onClose,
|
|
191
|
+
collections,
|
|
192
|
+
collectionsLoading,
|
|
193
|
+
selectedCollection,
|
|
194
|
+
onSelectCollection,
|
|
195
|
+
contentItems,
|
|
196
|
+
contentLoading,
|
|
197
|
+
selectedContent,
|
|
198
|
+
onSelectContent,
|
|
199
|
+
onCreateContent,
|
|
200
|
+
onRefreshCollections,
|
|
201
|
+
onRefreshContent,
|
|
202
|
+
}: SidebarProps): React.ReactElement {
|
|
203
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
204
|
+
const [filterDraft, setFilterDraft] = useState<"all" | "published" | "draft">(
|
|
205
|
+
"all"
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Focus indices for arrow navigation
|
|
209
|
+
const [collectionFocusIndex, setCollectionFocusIndex] = useState(0);
|
|
210
|
+
const [contentFocusIndex, setContentFocusIndex] = useState(0);
|
|
211
|
+
const [tabFocusIndex, setTabFocusIndex] = useState(0);
|
|
212
|
+
|
|
213
|
+
// Refs for list containers
|
|
214
|
+
const collectionListRef = useRef<HTMLUListElement>(null);
|
|
215
|
+
const contentListRef = useRef<HTMLUListElement>(null);
|
|
216
|
+
const tabListRef = useRef<HTMLDivElement>(null);
|
|
217
|
+
|
|
218
|
+
// Announcement hook for search results
|
|
219
|
+
const { announce } = useAnnounce();
|
|
220
|
+
|
|
221
|
+
// Previous filtered items count for announcements
|
|
222
|
+
const prevFilteredCountRef = useRef<number | null>(null);
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
onRefreshCollections();
|
|
226
|
+
}, [onRefreshCollections]);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (selectedCollection) {
|
|
230
|
+
onRefreshContent();
|
|
231
|
+
}
|
|
232
|
+
}, [selectedCollection, onRefreshContent]);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
setSearchQuery("");
|
|
236
|
+
}, [selectedCollection]);
|
|
237
|
+
|
|
238
|
+
const draftCount = useMemo(
|
|
239
|
+
() => contentItems.filter((item) => item.draft).length,
|
|
240
|
+
[contentItems]
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const publishedCount = useMemo(
|
|
244
|
+
() => contentItems.filter((item) => !item.draft).length,
|
|
245
|
+
[contentItems]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const filteredItems = useMemo(() => {
|
|
249
|
+
let items = contentItems;
|
|
250
|
+
|
|
251
|
+
if (filterDraft === "published") {
|
|
252
|
+
items = items.filter((item) => !item.draft);
|
|
253
|
+
} else if (filterDraft === "draft") {
|
|
254
|
+
items = items.filter((item) => item.draft);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (searchQuery.trim()) {
|
|
258
|
+
const query = searchQuery.toLowerCase();
|
|
259
|
+
items = items.filter(
|
|
260
|
+
(item) =>
|
|
261
|
+
item.title.toLowerCase().includes(query) ||
|
|
262
|
+
item.id.toLowerCase().includes(query)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return items;
|
|
267
|
+
}, [contentItems, searchQuery, filterDraft]);
|
|
268
|
+
|
|
269
|
+
// Generate IDs for collection items
|
|
270
|
+
const collectionIds = useMemo(
|
|
271
|
+
() => collections.map((col) => `wn-collection-${col.name}`),
|
|
272
|
+
[collections]
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Generate IDs for content items
|
|
276
|
+
const contentIds = useMemo(
|
|
277
|
+
() => filteredItems.map((item) => `wn-content-${item.id}`),
|
|
278
|
+
[filteredItems]
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Tab IDs for filter tabs
|
|
282
|
+
const tabIds = useMemo(
|
|
283
|
+
() => ["wn-tab-all", "wn-tab-published", "wn-tab-draft"],
|
|
284
|
+
[]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Arrow navigation for collections
|
|
288
|
+
const { handleKeyDown: handleCollectionKeyDown } = useArrowNavigation({
|
|
289
|
+
items: collectionIds,
|
|
290
|
+
currentIndex: collectionFocusIndex,
|
|
291
|
+
onIndexChange: setCollectionFocusIndex,
|
|
292
|
+
onSelect: (index) => {
|
|
293
|
+
if (collections[index]) {
|
|
294
|
+
onSelectCollection(collections[index].name);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
orientation: "vertical",
|
|
298
|
+
loop: true,
|
|
299
|
+
enabled: collections.length > 0,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Arrow navigation for content items
|
|
303
|
+
const { handleKeyDown: handleContentKeyDown } = useArrowNavigation({
|
|
304
|
+
items: contentIds,
|
|
305
|
+
currentIndex: contentFocusIndex,
|
|
306
|
+
onIndexChange: setContentFocusIndex,
|
|
307
|
+
onSelect: (index) => {
|
|
308
|
+
if (filteredItems[index]) {
|
|
309
|
+
onSelectContent(filteredItems[index].id);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
orientation: "vertical",
|
|
313
|
+
loop: true,
|
|
314
|
+
enabled: filteredItems.length > 0,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Arrow navigation for filter tabs (horizontal)
|
|
318
|
+
const { handleKeyDown: handleTabKeyDown } = useArrowNavigation({
|
|
319
|
+
items: tabIds,
|
|
320
|
+
currentIndex: tabFocusIndex,
|
|
321
|
+
onIndexChange: setTabFocusIndex,
|
|
322
|
+
onSelect: (index) => {
|
|
323
|
+
const filters: Array<"all" | "published" | "draft"> = [
|
|
324
|
+
"all",
|
|
325
|
+
"published",
|
|
326
|
+
"draft",
|
|
327
|
+
];
|
|
328
|
+
const filter = filters[index];
|
|
329
|
+
if (filter) {
|
|
330
|
+
setFilterDraft(filter);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
orientation: "horizontal",
|
|
334
|
+
loop: true,
|
|
335
|
+
enabled: true,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Update tab focus index when filter changes
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
const filterToIndex = { all: 0, published: 1, draft: 2 };
|
|
341
|
+
setTabFocusIndex(filterToIndex[filterDraft]);
|
|
342
|
+
}, [filterDraft]);
|
|
343
|
+
|
|
344
|
+
// Reset content focus index when filtered items change
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
setContentFocusIndex(0);
|
|
347
|
+
}, [filteredItems.length]);
|
|
348
|
+
|
|
349
|
+
// Announce search results when they change
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
// Only announce if we have a search query and the count has changed
|
|
352
|
+
if (searchQuery.trim()) {
|
|
353
|
+
const currentCount = filteredItems.length;
|
|
354
|
+
if (prevFilteredCountRef.current !== currentCount) {
|
|
355
|
+
const message =
|
|
356
|
+
currentCount === 0
|
|
357
|
+
? "No results found"
|
|
358
|
+
: currentCount === 1
|
|
359
|
+
? "1 result found"
|
|
360
|
+
: `${currentCount} results found`;
|
|
361
|
+
announce(message, "polite");
|
|
362
|
+
}
|
|
363
|
+
prevFilteredCountRef.current = currentCount;
|
|
364
|
+
} else {
|
|
365
|
+
prevFilteredCountRef.current = null;
|
|
366
|
+
}
|
|
367
|
+
}, [filteredItems.length, searchQuery, announce]);
|
|
368
|
+
|
|
369
|
+
const sidebarClassName = [
|
|
370
|
+
"wn-sidebar",
|
|
371
|
+
isOpen ? "wn-sidebar--open" : "wn-sidebar--closed",
|
|
372
|
+
]
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
.join(" ");
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<aside
|
|
378
|
+
className={sidebarClassName}
|
|
379
|
+
role="navigation"
|
|
380
|
+
aria-label="Content navigation"
|
|
381
|
+
aria-hidden={!isOpen}
|
|
382
|
+
>
|
|
383
|
+
<div className="wn-sidebar-inner">
|
|
384
|
+
{/* Header */}
|
|
385
|
+
<div className="wn-sidebar-header">
|
|
386
|
+
<h2 className="wn-sidebar-title">
|
|
387
|
+
<Folder size={16} />
|
|
388
|
+
Explorer
|
|
389
|
+
</h2>
|
|
390
|
+
<button
|
|
391
|
+
className="wn-sidebar-close"
|
|
392
|
+
onClick={onClose}
|
|
393
|
+
title="Close sidebar"
|
|
394
|
+
aria-label="Close sidebar"
|
|
395
|
+
>
|
|
396
|
+
<X size={12} />
|
|
397
|
+
</button>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
{/* Collections Section */}
|
|
401
|
+
<div className="wn-sidebar-section">
|
|
402
|
+
<div className="wn-sidebar-section-header">
|
|
403
|
+
<span className="wn-sidebar-section-title">Collections</span>
|
|
404
|
+
<div className="wn-sidebar-section-actions">
|
|
405
|
+
<button
|
|
406
|
+
className="wn-sidebar-icon-btn"
|
|
407
|
+
onClick={onRefreshCollections}
|
|
408
|
+
title="Refresh collections"
|
|
409
|
+
>
|
|
410
|
+
<RefreshCw size={14} />
|
|
411
|
+
</button>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
{collectionsLoading ? (
|
|
416
|
+
<div aria-busy="true" aria-label="Loading collections">
|
|
417
|
+
{[1, 2, 3].map((i) => (
|
|
418
|
+
<div key={i} className="wn-skeleton-collection">
|
|
419
|
+
<div className="wn-skeleton wn-skeleton-icon" />
|
|
420
|
+
<div className="wn-skeleton wn-skeleton-text wn-skeleton-text--short" />
|
|
421
|
+
<div className="wn-skeleton wn-skeleton-badge" />
|
|
422
|
+
</div>
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
) : collections.length === 0 ? (
|
|
426
|
+
<div className="wn-sidebar-empty">
|
|
427
|
+
<span className="wn-sidebar-empty-text">
|
|
428
|
+
No collections found
|
|
429
|
+
</span>
|
|
430
|
+
</div>
|
|
431
|
+
) : (
|
|
432
|
+
<ul
|
|
433
|
+
className="wn-collection-list"
|
|
434
|
+
role="listbox"
|
|
435
|
+
aria-label="Collections"
|
|
436
|
+
ref={collectionListRef}
|
|
437
|
+
onKeyDown={handleCollectionKeyDown}
|
|
438
|
+
>
|
|
439
|
+
{collections.map((col, index) => {
|
|
440
|
+
const itemId = collectionIds[index];
|
|
441
|
+
if (!itemId) return null;
|
|
442
|
+
return (
|
|
443
|
+
<CollectionItem
|
|
444
|
+
key={col.name}
|
|
445
|
+
collection={col}
|
|
446
|
+
isSelected={selectedCollection === col.name}
|
|
447
|
+
isFocused={index === collectionFocusIndex}
|
|
448
|
+
onSelect={onSelectCollection}
|
|
449
|
+
id={itemId}
|
|
450
|
+
/>
|
|
451
|
+
);
|
|
452
|
+
})}
|
|
453
|
+
</ul>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{/* Content Section */}
|
|
458
|
+
{selectedCollection && (
|
|
459
|
+
<div className="wn-sidebar-content">
|
|
460
|
+
<div className="wn-sidebar-section-header wn-sidebar-content-header">
|
|
461
|
+
<span className="wn-sidebar-section-title">
|
|
462
|
+
{selectedCollection}
|
|
463
|
+
</span>
|
|
464
|
+
<div className="wn-sidebar-section-actions">
|
|
465
|
+
<button
|
|
466
|
+
className="wn-sidebar-icon-btn"
|
|
467
|
+
onClick={onRefreshContent}
|
|
468
|
+
title="Refresh content"
|
|
469
|
+
>
|
|
470
|
+
<RefreshCw size={14} />
|
|
471
|
+
</button>
|
|
472
|
+
<button
|
|
473
|
+
className="wn-sidebar-icon-btn wn-sidebar-icon-btn--primary"
|
|
474
|
+
onClick={onCreateContent}
|
|
475
|
+
title="New content"
|
|
476
|
+
>
|
|
477
|
+
<Plus size={14} />
|
|
478
|
+
</button>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
{/* Search and Filter */}
|
|
483
|
+
{contentItems.length > 0 && (
|
|
484
|
+
<div className="wn-sidebar-search">
|
|
485
|
+
<div className="wn-search-input-wrapper">
|
|
486
|
+
<Search size={14} aria-hidden="true" />
|
|
487
|
+
<input
|
|
488
|
+
type="text"
|
|
489
|
+
placeholder="Search..."
|
|
490
|
+
value={searchQuery}
|
|
491
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
492
|
+
className="wn-search-input"
|
|
493
|
+
aria-label="Search content"
|
|
494
|
+
/>
|
|
495
|
+
{searchQuery && (
|
|
496
|
+
<button
|
|
497
|
+
className="wn-search-clear"
|
|
498
|
+
onClick={() => setSearchQuery("")}
|
|
499
|
+
title="Clear search"
|
|
500
|
+
aria-label="Clear search"
|
|
501
|
+
>
|
|
502
|
+
<X size={12} />
|
|
503
|
+
</button>
|
|
504
|
+
)}
|
|
505
|
+
</div>
|
|
506
|
+
<div
|
|
507
|
+
className="wn-filter-tabs"
|
|
508
|
+
role="tablist"
|
|
509
|
+
aria-label="Filter content"
|
|
510
|
+
ref={tabListRef}
|
|
511
|
+
onKeyDown={handleTabKeyDown}
|
|
512
|
+
>
|
|
513
|
+
<button
|
|
514
|
+
id={tabIds[0]}
|
|
515
|
+
className={`wn-filter-tab ${filterDraft === "all" ? "wn-filter-tab--active" : ""}`}
|
|
516
|
+
onClick={() => setFilterDraft("all")}
|
|
517
|
+
role="tab"
|
|
518
|
+
aria-selected={filterDraft === "all"}
|
|
519
|
+
tabIndex={tabFocusIndex === 0 ? 0 : -1}
|
|
520
|
+
>
|
|
521
|
+
All ({contentItems.length})
|
|
522
|
+
</button>
|
|
523
|
+
<button
|
|
524
|
+
id={tabIds[1]}
|
|
525
|
+
className={`wn-filter-tab ${filterDraft === "published" ? "wn-filter-tab--active" : ""}`}
|
|
526
|
+
onClick={() => setFilterDraft("published")}
|
|
527
|
+
role="tab"
|
|
528
|
+
aria-selected={filterDraft === "published"}
|
|
529
|
+
tabIndex={tabFocusIndex === 1 ? 0 : -1}
|
|
530
|
+
>
|
|
531
|
+
<CheckCircle size={10} aria-hidden="true" />
|
|
532
|
+
{publishedCount}
|
|
533
|
+
</button>
|
|
534
|
+
<button
|
|
535
|
+
id={tabIds[2]}
|
|
536
|
+
className={`wn-filter-tab ${filterDraft === "draft" ? "wn-filter-tab--active" : ""}`}
|
|
537
|
+
onClick={() => setFilterDraft("draft")}
|
|
538
|
+
role="tab"
|
|
539
|
+
aria-selected={filterDraft === "draft"}
|
|
540
|
+
tabIndex={tabFocusIndex === 2 ? 0 : -1}
|
|
541
|
+
>
|
|
542
|
+
<FileEdit size={10} aria-hidden="true" />
|
|
543
|
+
{draftCount}
|
|
544
|
+
</button>
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{/* Content List */}
|
|
550
|
+
{contentLoading ? (
|
|
551
|
+
<div
|
|
552
|
+
className="wn-content-list"
|
|
553
|
+
aria-busy="true"
|
|
554
|
+
aria-label="Loading content"
|
|
555
|
+
>
|
|
556
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
557
|
+
<div key={i} className="wn-skeleton-content">
|
|
558
|
+
<div className="wn-skeleton wn-skeleton-title" />
|
|
559
|
+
<div className="wn-skeleton wn-skeleton-date" />
|
|
560
|
+
</div>
|
|
561
|
+
))}
|
|
562
|
+
</div>
|
|
563
|
+
) : contentItems.length === 0 ? (
|
|
564
|
+
<div className="wn-sidebar-empty">
|
|
565
|
+
<span className="wn-sidebar-empty-text">No content yet.</span>
|
|
566
|
+
<button
|
|
567
|
+
className="wn-sidebar-empty-link"
|
|
568
|
+
onClick={onCreateContent}
|
|
569
|
+
>
|
|
570
|
+
Create your first post
|
|
571
|
+
</button>
|
|
572
|
+
</div>
|
|
573
|
+
) : filteredItems.length === 0 ? (
|
|
574
|
+
<div className="wn-sidebar-empty">
|
|
575
|
+
<span className="wn-sidebar-empty-text">
|
|
576
|
+
No matching content.
|
|
577
|
+
</span>
|
|
578
|
+
<button
|
|
579
|
+
className="wn-sidebar-empty-link"
|
|
580
|
+
onClick={() => {
|
|
581
|
+
setSearchQuery("");
|
|
582
|
+
setFilterDraft("all");
|
|
583
|
+
}}
|
|
584
|
+
>
|
|
585
|
+
Clear filters
|
|
586
|
+
</button>
|
|
587
|
+
</div>
|
|
588
|
+
) : (
|
|
589
|
+
<ul
|
|
590
|
+
className="wn-content-list"
|
|
591
|
+
role="listbox"
|
|
592
|
+
aria-label="Content items"
|
|
593
|
+
ref={contentListRef}
|
|
594
|
+
onKeyDown={handleContentKeyDown}
|
|
595
|
+
>
|
|
596
|
+
{filteredItems.map((item, index) => {
|
|
597
|
+
const itemId = contentIds[index];
|
|
598
|
+
if (!itemId) return null;
|
|
599
|
+
return (
|
|
600
|
+
<ContentListItem
|
|
601
|
+
key={item.id}
|
|
602
|
+
item={item}
|
|
603
|
+
isSelected={selectedContent === item.id}
|
|
604
|
+
isFocused={index === contentFocusIndex}
|
|
605
|
+
onSelect={onSelectContent}
|
|
606
|
+
id={itemId}
|
|
607
|
+
/>
|
|
608
|
+
);
|
|
609
|
+
})}
|
|
610
|
+
</ul>
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
)}
|
|
614
|
+
</div>
|
|
615
|
+
</aside>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Skip Link component styles
|
|
3
|
+
*
|
|
4
|
+
* Styles for the skip link component that is visually hidden
|
|
5
|
+
* but becomes visible when focused.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* Skip link - visually hidden by default */
|
|
9
|
+
.wn-skip-link {
|
|
10
|
+
position: absolute;
|
|
11
|
+
top: -100%;
|
|
12
|
+
left: 0;
|
|
13
|
+
z-index: var(--wn-z-skip);
|
|
14
|
+
padding: var(--wn-space-4) var(--wn-space-5);
|
|
15
|
+
background-color: var(--wn-brand-500);
|
|
16
|
+
color: white;
|
|
17
|
+
font-size: var(--wn-font-base);
|
|
18
|
+
font-weight: 500;
|
|
19
|
+
text-decoration: none;
|
|
20
|
+
border-radius: 0 0 var(--wn-radius-md) 0;
|
|
21
|
+
transition: top var(--wn-transition-fast) ease-in-out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Skip link - visible on focus */
|
|
25
|
+
.wn-skip-link:focus {
|
|
26
|
+
top: 0;
|
|
27
|
+
outline: 2px solid var(--wn-brand-400);
|
|
28
|
+
outline-offset: 2px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Skip link - hover state */
|
|
32
|
+
.wn-skip-link:hover {
|
|
33
|
+
background-color: var(--wn-brand-600);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Light mode overrides */
|
|
37
|
+
.wn-light .wn-skip-link {
|
|
38
|
+
background-color: var(--wn-brand-500);
|
|
39
|
+
color: white;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.wn-light .wn-skip-link:focus {
|
|
43
|
+
outline-color: var(--wn-brand-500);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Reduced motion - disable transition */
|
|
47
|
+
@media (prefers-reduced-motion: reduce) {
|
|
48
|
+
.wn-skip-link {
|
|
49
|
+
transition: none;
|
|
50
|
+
}
|
|
51
|
+
}
|