@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,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview API hooks for Writenex client
|
|
3
|
+
*
|
|
4
|
+
* Custom React hooks for interacting with the Writenex API.
|
|
5
|
+
* These hooks use the shared API client from ApiContext when available,
|
|
6
|
+
* falling back to creating a new client for standalone usage.
|
|
7
|
+
*
|
|
8
|
+
* @module @writenex/astro/client/hooks/useApi
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useCallback, useMemo } from "react";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collection data from API
|
|
15
|
+
*/
|
|
16
|
+
export interface Collection {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
filePattern: string;
|
|
20
|
+
count: number;
|
|
21
|
+
schema?: Record<string, unknown>;
|
|
22
|
+
previewUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Content summary for listing
|
|
27
|
+
*/
|
|
28
|
+
export interface ContentSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
path: string;
|
|
31
|
+
title: string;
|
|
32
|
+
pubDate?: string;
|
|
33
|
+
draft?: boolean;
|
|
34
|
+
excerpt?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Full content item
|
|
39
|
+
*/
|
|
40
|
+
export interface ContentItem {
|
|
41
|
+
id: string;
|
|
42
|
+
path: string;
|
|
43
|
+
frontmatter: Record<string, unknown>;
|
|
44
|
+
body: string;
|
|
45
|
+
raw: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* API client configuration
|
|
50
|
+
*/
|
|
51
|
+
interface ApiConfig {
|
|
52
|
+
apiBase: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Image configuration from API
|
|
57
|
+
*/
|
|
58
|
+
export interface ImageConfig {
|
|
59
|
+
strategy: "colocated" | "public" | "custom";
|
|
60
|
+
publicPath?: string;
|
|
61
|
+
storagePath?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Editor configuration from API
|
|
66
|
+
*/
|
|
67
|
+
export interface EditorConfig {
|
|
68
|
+
autosave?: boolean;
|
|
69
|
+
autosaveInterval?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Writenex configuration from API
|
|
74
|
+
*/
|
|
75
|
+
export interface WritenexClientConfig {
|
|
76
|
+
images?: ImageConfig;
|
|
77
|
+
editor?: EditorConfig;
|
|
78
|
+
/** Astro's trailingSlash setting for preview URLs */
|
|
79
|
+
trailingSlash?: "always" | "never" | "ignore";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Config path response from API
|
|
84
|
+
*/
|
|
85
|
+
export interface ConfigPathResponse {
|
|
86
|
+
configPath: string | null;
|
|
87
|
+
projectRoot: string;
|
|
88
|
+
hasConfigFile: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create API client functions
|
|
93
|
+
*/
|
|
94
|
+
export function createApiClient(config: ApiConfig) {
|
|
95
|
+
const { apiBase } = config;
|
|
96
|
+
|
|
97
|
+
// Extract basePath from apiBase (remove /api suffix)
|
|
98
|
+
const basePath = apiBase.replace(/\/api$/, "");
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
/** Base path for the Writenex editor (without /api) */
|
|
102
|
+
basePath,
|
|
103
|
+
/**
|
|
104
|
+
* Fetch configuration
|
|
105
|
+
*/
|
|
106
|
+
async getConfig(): Promise<WritenexClientConfig> {
|
|
107
|
+
const response = await fetch(`${apiBase}/config`);
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
throw new Error("Failed to fetch config");
|
|
110
|
+
}
|
|
111
|
+
return response.json();
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fetch config file path for opening in editor
|
|
116
|
+
*/
|
|
117
|
+
async getConfigPath(): Promise<ConfigPathResponse> {
|
|
118
|
+
const response = await fetch(`${apiBase}/config/path`);
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error("Failed to fetch config path");
|
|
121
|
+
}
|
|
122
|
+
return response.json();
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fetch all collections
|
|
127
|
+
*/
|
|
128
|
+
async getCollections(): Promise<Collection[]> {
|
|
129
|
+
const response = await fetch(`${apiBase}/collections`);
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new Error("Failed to fetch collections");
|
|
132
|
+
}
|
|
133
|
+
const data = await response.json();
|
|
134
|
+
return data.collections;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fetch content list for a collection
|
|
139
|
+
*/
|
|
140
|
+
async getContentList(
|
|
141
|
+
collection: string,
|
|
142
|
+
options?: {
|
|
143
|
+
includeDrafts?: boolean;
|
|
144
|
+
sort?: string;
|
|
145
|
+
order?: "asc" | "desc";
|
|
146
|
+
}
|
|
147
|
+
): Promise<ContentSummary[]> {
|
|
148
|
+
const params = new URLSearchParams();
|
|
149
|
+
if (options?.includeDrafts) params.set("draft", "true");
|
|
150
|
+
if (options?.sort) params.set("sort", options.sort);
|
|
151
|
+
if (options?.order) params.set("order", options.order);
|
|
152
|
+
|
|
153
|
+
const url = `${apiBase}/content/${collection}${params.toString() ? `?${params}` : ""}`;
|
|
154
|
+
const response = await fetch(url);
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new Error("Failed to fetch content list");
|
|
157
|
+
}
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
return data.items;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fetch single content item
|
|
164
|
+
*/
|
|
165
|
+
async getContent(collection: string, id: string): Promise<ContentItem> {
|
|
166
|
+
const response = await fetch(`${apiBase}/content/${collection}/${id}`);
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
throw new Error("Failed to fetch content");
|
|
169
|
+
}
|
|
170
|
+
return response.json();
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create new content
|
|
175
|
+
*/
|
|
176
|
+
async createContent(
|
|
177
|
+
collection: string,
|
|
178
|
+
data: {
|
|
179
|
+
frontmatter: Record<string, unknown>;
|
|
180
|
+
body: string;
|
|
181
|
+
slug?: string;
|
|
182
|
+
}
|
|
183
|
+
): Promise<{
|
|
184
|
+
success: boolean;
|
|
185
|
+
id?: string;
|
|
186
|
+
path?: string;
|
|
187
|
+
error?: string;
|
|
188
|
+
}> {
|
|
189
|
+
const response = await fetch(`${apiBase}/content/${collection}`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
body: JSON.stringify(data),
|
|
193
|
+
});
|
|
194
|
+
return response.json();
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Update content
|
|
199
|
+
*/
|
|
200
|
+
async updateContent(
|
|
201
|
+
collection: string,
|
|
202
|
+
id: string,
|
|
203
|
+
data: { frontmatter?: Record<string, unknown>; body?: string }
|
|
204
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
205
|
+
const response = await fetch(`${apiBase}/content/${collection}/${id}`, {
|
|
206
|
+
method: "PUT",
|
|
207
|
+
headers: { "Content-Type": "application/json" },
|
|
208
|
+
body: JSON.stringify(data),
|
|
209
|
+
});
|
|
210
|
+
return response.json();
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Delete content
|
|
215
|
+
*/
|
|
216
|
+
async deleteContent(
|
|
217
|
+
collection: string,
|
|
218
|
+
id: string
|
|
219
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
220
|
+
const response = await fetch(`${apiBase}/content/${collection}/${id}`, {
|
|
221
|
+
method: "DELETE",
|
|
222
|
+
});
|
|
223
|
+
return response.json();
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Upload image
|
|
228
|
+
*/
|
|
229
|
+
async uploadImage(
|
|
230
|
+
file: File,
|
|
231
|
+
collection: string,
|
|
232
|
+
contentId: string
|
|
233
|
+
): Promise<{
|
|
234
|
+
success: boolean;
|
|
235
|
+
path?: string;
|
|
236
|
+
url?: string;
|
|
237
|
+
error?: string;
|
|
238
|
+
}> {
|
|
239
|
+
const formData = new FormData();
|
|
240
|
+
formData.append("file", file);
|
|
241
|
+
formData.append("collection", collection);
|
|
242
|
+
formData.append("contentId", contentId);
|
|
243
|
+
|
|
244
|
+
const response = await fetch(`${apiBase}/images`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: formData,
|
|
247
|
+
});
|
|
248
|
+
return response.json();
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hook for using the API client
|
|
255
|
+
*
|
|
256
|
+
* Creates a memoized API client instance that persists across re-renders.
|
|
257
|
+
* For shared usage across the app, consider using ApiProvider and useSharedApi instead.
|
|
258
|
+
*/
|
|
259
|
+
export function useApi(apiBase: string) {
|
|
260
|
+
const client = useMemo(() => createApiClient({ apiBase }), [apiBase]);
|
|
261
|
+
return client;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** API client type */
|
|
265
|
+
export type ApiClient = ReturnType<typeof createApiClient>;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Hook for fetching collections
|
|
269
|
+
*
|
|
270
|
+
* @param apiBaseOrClient - Either an API base URL string or a pre-created API client
|
|
271
|
+
*/
|
|
272
|
+
export function useCollections(apiBaseOrClient: string | ApiClient) {
|
|
273
|
+
const client = useMemo(() => {
|
|
274
|
+
if (typeof apiBaseOrClient === "string") {
|
|
275
|
+
return createApiClient({ apiBase: apiBaseOrClient });
|
|
276
|
+
}
|
|
277
|
+
return apiBaseOrClient;
|
|
278
|
+
}, [apiBaseOrClient]);
|
|
279
|
+
const [collections, setCollections] = useState<Collection[]>([]);
|
|
280
|
+
const [loading, setLoading] = useState(true);
|
|
281
|
+
const [error, setError] = useState<string | null>(null);
|
|
282
|
+
|
|
283
|
+
const refresh = useCallback(async () => {
|
|
284
|
+
setLoading(true);
|
|
285
|
+
setError(null);
|
|
286
|
+
try {
|
|
287
|
+
const data = await client.getCollections();
|
|
288
|
+
setCollections(data);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
setError(
|
|
291
|
+
err instanceof Error ? err.message : "Failed to fetch collections"
|
|
292
|
+
);
|
|
293
|
+
} finally {
|
|
294
|
+
setLoading(false);
|
|
295
|
+
}
|
|
296
|
+
}, [client]);
|
|
297
|
+
|
|
298
|
+
return { collections, loading, error, refresh };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Hook for fetching content list
|
|
303
|
+
*
|
|
304
|
+
* @param apiBaseOrClient - Either an API base URL string or a pre-created API client
|
|
305
|
+
* @param collection - Collection name to fetch content from
|
|
306
|
+
*/
|
|
307
|
+
export function useContentList(
|
|
308
|
+
apiBaseOrClient: string | ApiClient,
|
|
309
|
+
collection: string | null
|
|
310
|
+
) {
|
|
311
|
+
const client = useMemo(() => {
|
|
312
|
+
if (typeof apiBaseOrClient === "string") {
|
|
313
|
+
return createApiClient({ apiBase: apiBaseOrClient });
|
|
314
|
+
}
|
|
315
|
+
return apiBaseOrClient;
|
|
316
|
+
}, [apiBaseOrClient]);
|
|
317
|
+
const [items, setItems] = useState<ContentSummary[]>([]);
|
|
318
|
+
const [loading, setLoading] = useState(false);
|
|
319
|
+
const [error, setError] = useState<string | null>(null);
|
|
320
|
+
|
|
321
|
+
const refresh = useCallback(async () => {
|
|
322
|
+
if (!collection) {
|
|
323
|
+
setItems([]);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
setLoading(true);
|
|
328
|
+
setError(null);
|
|
329
|
+
try {
|
|
330
|
+
const data = await client.getContentList(collection, {
|
|
331
|
+
includeDrafts: true,
|
|
332
|
+
});
|
|
333
|
+
setItems(data);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
setError(err instanceof Error ? err.message : "Failed to fetch content");
|
|
336
|
+
} finally {
|
|
337
|
+
setLoading(false);
|
|
338
|
+
}
|
|
339
|
+
}, [client, collection]);
|
|
340
|
+
|
|
341
|
+
return { items, loading, error, refresh };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Hook for fetching configuration
|
|
346
|
+
*
|
|
347
|
+
* @param apiBaseOrClient - Either an API base URL string or a pre-created API client
|
|
348
|
+
*/
|
|
349
|
+
export function useConfig(apiBaseOrClient: string | ApiClient) {
|
|
350
|
+
const client = useMemo(() => {
|
|
351
|
+
if (typeof apiBaseOrClient === "string") {
|
|
352
|
+
return createApiClient({ apiBase: apiBaseOrClient });
|
|
353
|
+
}
|
|
354
|
+
return apiBaseOrClient;
|
|
355
|
+
}, [apiBaseOrClient]);
|
|
356
|
+
const [config, setConfig] = useState<WritenexClientConfig | null>(null);
|
|
357
|
+
const [loading, setLoading] = useState(true);
|
|
358
|
+
const [error, setError] = useState<string | null>(null);
|
|
359
|
+
|
|
360
|
+
const refresh = useCallback(async () => {
|
|
361
|
+
setLoading(true);
|
|
362
|
+
setError(null);
|
|
363
|
+
try {
|
|
364
|
+
const data = await client.getConfig();
|
|
365
|
+
setConfig(data);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
setError(err instanceof Error ? err.message : "Failed to fetch config");
|
|
368
|
+
} finally {
|
|
369
|
+
setLoading(false);
|
|
370
|
+
}
|
|
371
|
+
}, [client]);
|
|
372
|
+
|
|
373
|
+
return { config, loading, error, refresh };
|
|
374
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Arrow key navigation hook for list accessibility
|
|
3
|
+
*
|
|
4
|
+
* This hook provides arrow key navigation for lists and tab panels,
|
|
5
|
+
* supporting both vertical (ArrowUp/ArrowDown) and horizontal
|
|
6
|
+
* (ArrowLeft/ArrowRight) navigation patterns.
|
|
7
|
+
*
|
|
8
|
+
* ## Features:
|
|
9
|
+
* - Vertical arrow key navigation for lists
|
|
10
|
+
* - Horizontal arrow key navigation for tabs
|
|
11
|
+
* - Loop and non-loop modes
|
|
12
|
+
* - Enter key for selection
|
|
13
|
+
* - Home/End key support
|
|
14
|
+
*
|
|
15
|
+
* @module @writenex/astro/client/hooks/useArrowNavigation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Navigation orientation
|
|
22
|
+
*/
|
|
23
|
+
export type NavigationOrientation = "vertical" | "horizontal";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for useArrowNavigation hook
|
|
27
|
+
*/
|
|
28
|
+
export interface UseArrowNavigationOptions {
|
|
29
|
+
/** List of item IDs or refs */
|
|
30
|
+
items: string[] | React.RefObject<HTMLElement | null>[];
|
|
31
|
+
/** Current focused index */
|
|
32
|
+
currentIndex: number;
|
|
33
|
+
/** Callback when index changes */
|
|
34
|
+
onIndexChange: (index: number) => void;
|
|
35
|
+
/** Callback when Enter is pressed on an item */
|
|
36
|
+
onSelect?: (index: number) => void;
|
|
37
|
+
/** Whether navigation is vertical (default) or horizontal */
|
|
38
|
+
orientation?: NavigationOrientation;
|
|
39
|
+
/** Whether to loop at boundaries (default: true) */
|
|
40
|
+
loop?: boolean;
|
|
41
|
+
/** Whether the navigation is enabled (default: true) */
|
|
42
|
+
enabled?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Return value from useArrowNavigation hook
|
|
47
|
+
*/
|
|
48
|
+
export interface UseArrowNavigationReturn {
|
|
49
|
+
/** Ref to attach to the container element */
|
|
50
|
+
containerRef: React.RefObject<HTMLElement | null>;
|
|
51
|
+
/** Handle keydown event (for manual attachment) */
|
|
52
|
+
handleKeyDown: (event: React.KeyboardEvent) => void;
|
|
53
|
+
/** Move focus to next item */
|
|
54
|
+
focusNext: () => void;
|
|
55
|
+
/** Move focus to previous item */
|
|
56
|
+
focusPrevious: () => void;
|
|
57
|
+
/** Move focus to first item */
|
|
58
|
+
focusFirst: () => void;
|
|
59
|
+
/** Move focus to last item */
|
|
60
|
+
focusLast: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the element at a given index from items array
|
|
65
|
+
*/
|
|
66
|
+
function getElementAtIndex(
|
|
67
|
+
items: string[] | React.RefObject<HTMLElement | null>[],
|
|
68
|
+
index: number
|
|
69
|
+
): HTMLElement | null {
|
|
70
|
+
if (index < 0 || index >= items.length) return null;
|
|
71
|
+
|
|
72
|
+
const item = items[index];
|
|
73
|
+
|
|
74
|
+
// Check if it's a ref
|
|
75
|
+
if (item && typeof item === "object" && "current" in item) {
|
|
76
|
+
return item.current;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// It's an ID string
|
|
80
|
+
if (typeof item === "string") {
|
|
81
|
+
return document.getElementById(item);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hook for arrow key navigation in lists and tab panels
|
|
89
|
+
*
|
|
90
|
+
* This hook implements the WAI-ARIA keyboard navigation patterns
|
|
91
|
+
* for lists and tabs, allowing users to navigate using arrow keys.
|
|
92
|
+
*
|
|
93
|
+
* @param options - Navigation configuration options
|
|
94
|
+
* @returns Object containing container ref and navigation functions
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```tsx
|
|
98
|
+
* function CollectionList({ collections, selectedIndex, onSelect }) {
|
|
99
|
+
* const { containerRef, handleKeyDown } = useArrowNavigation({
|
|
100
|
+
* items: collections.map(c => c.id),
|
|
101
|
+
* currentIndex: selectedIndex,
|
|
102
|
+
* onIndexChange: setSelectedIndex,
|
|
103
|
+
* onSelect: (index) => onSelect(collections[index]),
|
|
104
|
+
* orientation: 'vertical',
|
|
105
|
+
* loop: true,
|
|
106
|
+
* });
|
|
107
|
+
*
|
|
108
|
+
* return (
|
|
109
|
+
* <ul ref={containerRef} role="listbox" onKeyDown={handleKeyDown}>
|
|
110
|
+
* {collections.map((collection, index) => (
|
|
111
|
+
* <li
|
|
112
|
+
* key={collection.id}
|
|
113
|
+
* id={collection.id}
|
|
114
|
+
* role="option"
|
|
115
|
+
* aria-selected={index === selectedIndex}
|
|
116
|
+
* tabIndex={index === selectedIndex ? 0 : -1}
|
|
117
|
+
* >
|
|
118
|
+
* {collection.name}
|
|
119
|
+
* </li>
|
|
120
|
+
* ))}
|
|
121
|
+
* </ul>
|
|
122
|
+
* );
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function useArrowNavigation(
|
|
127
|
+
options: UseArrowNavigationOptions
|
|
128
|
+
): UseArrowNavigationReturn {
|
|
129
|
+
const {
|
|
130
|
+
items,
|
|
131
|
+
currentIndex,
|
|
132
|
+
onIndexChange,
|
|
133
|
+
onSelect,
|
|
134
|
+
orientation = "vertical",
|
|
135
|
+
loop = true,
|
|
136
|
+
enabled = true,
|
|
137
|
+
} = options;
|
|
138
|
+
|
|
139
|
+
const containerRef = useRef<HTMLElement | null>(null);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Calculate the next index based on direction
|
|
143
|
+
*/
|
|
144
|
+
const getNextIndex = useCallback(
|
|
145
|
+
(direction: "next" | "previous"): number => {
|
|
146
|
+
if (items.length === 0) return -1;
|
|
147
|
+
|
|
148
|
+
if (direction === "next") {
|
|
149
|
+
const nextIndex = currentIndex + 1;
|
|
150
|
+
if (nextIndex >= items.length) {
|
|
151
|
+
return loop ? 0 : currentIndex;
|
|
152
|
+
}
|
|
153
|
+
return nextIndex;
|
|
154
|
+
} else {
|
|
155
|
+
const prevIndex = currentIndex - 1;
|
|
156
|
+
if (prevIndex < 0) {
|
|
157
|
+
return loop ? items.length - 1 : currentIndex;
|
|
158
|
+
}
|
|
159
|
+
return prevIndex;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[items.length, currentIndex, loop]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Move focus to next item
|
|
167
|
+
*/
|
|
168
|
+
const focusNext = useCallback(() => {
|
|
169
|
+
const nextIndex = getNextIndex("next");
|
|
170
|
+
if (nextIndex !== currentIndex) {
|
|
171
|
+
onIndexChange(nextIndex);
|
|
172
|
+
const element = getElementAtIndex(items, nextIndex);
|
|
173
|
+
element?.focus();
|
|
174
|
+
}
|
|
175
|
+
}, [getNextIndex, currentIndex, onIndexChange, items]);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Move focus to previous item
|
|
179
|
+
*/
|
|
180
|
+
const focusPrevious = useCallback(() => {
|
|
181
|
+
const prevIndex = getNextIndex("previous");
|
|
182
|
+
if (prevIndex !== currentIndex) {
|
|
183
|
+
onIndexChange(prevIndex);
|
|
184
|
+
const element = getElementAtIndex(items, prevIndex);
|
|
185
|
+
element?.focus();
|
|
186
|
+
}
|
|
187
|
+
}, [getNextIndex, currentIndex, onIndexChange, items]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Move focus to first item
|
|
191
|
+
*/
|
|
192
|
+
const focusFirst = useCallback(() => {
|
|
193
|
+
if (items.length === 0) return;
|
|
194
|
+
onIndexChange(0);
|
|
195
|
+
const element = getElementAtIndex(items, 0);
|
|
196
|
+
element?.focus();
|
|
197
|
+
}, [items, onIndexChange]);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Move focus to last item
|
|
201
|
+
*/
|
|
202
|
+
const focusLast = useCallback(() => {
|
|
203
|
+
if (items.length === 0) return;
|
|
204
|
+
const lastIndex = items.length - 1;
|
|
205
|
+
onIndexChange(lastIndex);
|
|
206
|
+
const element = getElementAtIndex(items, lastIndex);
|
|
207
|
+
element?.focus();
|
|
208
|
+
}, [items, onIndexChange]);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Handle keydown event
|
|
212
|
+
*/
|
|
213
|
+
const handleKeyDown = useCallback(
|
|
214
|
+
(event: React.KeyboardEvent) => {
|
|
215
|
+
if (!enabled || items.length === 0) return;
|
|
216
|
+
|
|
217
|
+
const isVertical = orientation === "vertical";
|
|
218
|
+
const nextKey = isVertical ? "ArrowDown" : "ArrowRight";
|
|
219
|
+
const prevKey = isVertical ? "ArrowUp" : "ArrowLeft";
|
|
220
|
+
|
|
221
|
+
switch (event.key) {
|
|
222
|
+
case nextKey:
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
focusNext();
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case prevKey:
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
focusPrevious();
|
|
230
|
+
break;
|
|
231
|
+
|
|
232
|
+
case "Home":
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
focusFirst();
|
|
235
|
+
break;
|
|
236
|
+
|
|
237
|
+
case "End":
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
focusLast();
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case "Enter":
|
|
243
|
+
case " ":
|
|
244
|
+
if (onSelect && currentIndex >= 0) {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
onSelect(currentIndex);
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[
|
|
252
|
+
enabled,
|
|
253
|
+
items.length,
|
|
254
|
+
orientation,
|
|
255
|
+
focusNext,
|
|
256
|
+
focusPrevious,
|
|
257
|
+
focusFirst,
|
|
258
|
+
focusLast,
|
|
259
|
+
onSelect,
|
|
260
|
+
currentIndex,
|
|
261
|
+
]
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Focus the current item when index changes externally
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!enabled || currentIndex < 0) return;
|
|
267
|
+
|
|
268
|
+
const element = getElementAtIndex(items, currentIndex);
|
|
269
|
+
if (element && document.activeElement !== element) {
|
|
270
|
+
// Only focus if the container or one of its children has focus
|
|
271
|
+
const container = containerRef.current;
|
|
272
|
+
if (container && container.contains(document.activeElement)) {
|
|
273
|
+
element.focus();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}, [enabled, currentIndex, items]);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
containerRef,
|
|
280
|
+
handleKeyDown,
|
|
281
|
+
focusNext,
|
|
282
|
+
focusPrevious,
|
|
283
|
+
focusFirst,
|
|
284
|
+
focusLast,
|
|
285
|
+
};
|
|
286
|
+
}
|