@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,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,7 @@
1
+ /**
2
+ * @fileoverview Sidebar component exports
3
+ *
4
+ * @module @writenex/astro/client/components/Sidebar
5
+ */
6
+
7
+ export { Sidebar } from "./Sidebar";
@@ -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
+ }