@nuasite/collections-admin 0.43.0-beta.4 → 0.43.0-beta.8
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/dist/types/app.d.ts +11 -6
- package/dist/types/app.d.ts.map +1 -1
- package/dist/types/entry-create.d.ts +1 -1
- package/dist/types/entry-create.d.ts.map +1 -1
- package/dist/types/entry-editor.d.ts +1 -1
- package/dist/types/entry-editor.d.ts.map +1 -1
- package/dist/types/field-editor.d.ts +1 -1
- package/dist/types/field-editor.d.ts.map +1 -1
- package/dist/types/media-picker.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/src/app.tsx +181 -109
- package/src/entry-editor.tsx +224 -36
- package/src/field-editor.tsx +54 -5
- package/src/media-picker.tsx +16 -2
- package/src/styles.css +220 -1
- package/src/tsconfig.json +1 -0
- package/dist/types/app.js +0 -240
- package/dist/types/client.d.ts +0 -149
- package/dist/types/client.d.ts.map +0 -1
- package/dist/types/client.js +0 -134
- package/dist/types/field-view.d.ts +0 -17
- package/dist/types/field-view.d.ts.map +0 -1
- package/dist/types/field-view.js +0 -77
- package/dist/types/form-model.d.ts +0 -61
- package/dist/types/form-model.d.ts.map +0 -1
- package/dist/types/index.js +0 -8
package/src/app.tsx
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* collections-admin SPA
|
|
2
|
+
* collections-admin SPA — a standalone CMS admin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Layout: a persistent left **sidebar of content types** (collections) and a main
|
|
5
|
+
* pane that shows the selected collection's entries list, the entry editor, or the
|
|
6
|
+
* create form. Navigation is **hash-routed** (`#/c/<collection>/e/<slug>`), so
|
|
7
|
+
* reloads and deep links work and back/forward behave.
|
|
8
|
+
*
|
|
9
|
+
* Host-agnostic: driven only by an `apiBase` prop, and the hash router keeps it
|
|
10
|
+
* self-contained for its standalone consumers (cms-studio, the F7 `/_nua/admin`
|
|
11
|
+
* page) without reaching for a host router. The webmaster Collections tab ships
|
|
12
|
+
* its own UI, so hijacking the URL hash here is safe.
|
|
8
13
|
*
|
|
9
14
|
* Browse collections, list entries (sparse projection + cursor pagination), and
|
|
10
15
|
* edit an entry: a field→widget form built from `FieldDefinition[]` with debounced
|
|
@@ -19,7 +24,7 @@ import { EntryEditor } from './entry-editor'
|
|
|
19
24
|
import './styles.css'
|
|
20
25
|
|
|
21
26
|
// ============================================================================
|
|
22
|
-
// View state
|
|
27
|
+
// View state + hash routing
|
|
23
28
|
// ============================================================================
|
|
24
29
|
|
|
25
30
|
type View =
|
|
@@ -28,6 +33,32 @@ type View =
|
|
|
28
33
|
| { view: 'detail'; collection: string; slug: string }
|
|
29
34
|
| { view: 'create'; collection: string }
|
|
30
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Parse the URL hash into a view. Slugs may contain `/` (nested glob entries),
|
|
38
|
+
* so they are carried URL-encoded in a single `…/e/<slug>` segment.
|
|
39
|
+
*/
|
|
40
|
+
function parseRoute(hash: string): View {
|
|
41
|
+
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean)
|
|
42
|
+
if (parts[0] !== 'c' || parts[1] === undefined) return { view: 'list' }
|
|
43
|
+
const collection = decodeURIComponent(parts[1])
|
|
44
|
+
if (parts[2] === 'new') return { view: 'create', collection }
|
|
45
|
+
if (parts[2] === 'e' && parts[3] !== undefined) return { view: 'detail', collection, slug: decodeURIComponent(parts[3]) }
|
|
46
|
+
return { view: 'entries', collection }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function routeToHash(view: View): string {
|
|
50
|
+
switch (view.view) {
|
|
51
|
+
case 'list':
|
|
52
|
+
return '#/'
|
|
53
|
+
case 'entries':
|
|
54
|
+
return `#/c/${encodeURIComponent(view.collection)}`
|
|
55
|
+
case 'create':
|
|
56
|
+
return `#/c/${encodeURIComponent(view.collection)}/new`
|
|
57
|
+
case 'detail':
|
|
58
|
+
return `#/c/${encodeURIComponent(view.collection)}/e/${encodeURIComponent(view.slug)}`
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
31
62
|
// ============================================================================
|
|
32
63
|
// Shared async-load hook
|
|
33
64
|
// ============================================================================
|
|
@@ -100,32 +131,60 @@ function EmptyState({ label }: { label: string }) {
|
|
|
100
131
|
}
|
|
101
132
|
|
|
102
133
|
// ============================================================================
|
|
103
|
-
//
|
|
134
|
+
// Sidebar — content-type menu
|
|
104
135
|
// ============================================================================
|
|
105
136
|
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
function Sidebar({ collections, loading, error, activeCollection, onSelect, onReload, onClose }: {
|
|
138
|
+
collections: CollectionDefinition[]
|
|
139
|
+
loading: boolean
|
|
140
|
+
error: CmsClientError | Error | null
|
|
141
|
+
activeCollection: string | undefined
|
|
142
|
+
onSelect: (collection: string) => void
|
|
143
|
+
onReload: () => void
|
|
144
|
+
onClose?: () => void
|
|
145
|
+
}) {
|
|
146
|
+
// Group glob-nested collections (e.g. `jsem-otazky` under `jsem`) below their
|
|
147
|
+
// parent, indented, so the menu mirrors the content structure.
|
|
148
|
+
const childrenByParent = new Map<string, CollectionDefinition[]>()
|
|
149
|
+
for (const c of collections) {
|
|
150
|
+
if (!c.parentCollection) continue
|
|
151
|
+
const arr = childrenByParent.get(c.parentCollection) ?? []
|
|
152
|
+
arr.push(c)
|
|
153
|
+
childrenByParent.set(c.parentCollection, arr)
|
|
154
|
+
}
|
|
155
|
+
const topLevel = collections.filter((c) => !c.parentCollection)
|
|
156
|
+
|
|
157
|
+
const item = (c: CollectionDefinition, depth: number) => (
|
|
158
|
+
<button
|
|
159
|
+
key={c.name}
|
|
160
|
+
type="button"
|
|
161
|
+
className={`nua-cadmin-nav-item${c.name === activeCollection ? ' is-active' : ''}`}
|
|
162
|
+
style={depth > 0 ? { paddingLeft: 12 + depth * 14 } : undefined}
|
|
163
|
+
onClick={() => onSelect(c.name)}
|
|
164
|
+
title={c.name}
|
|
165
|
+
>
|
|
166
|
+
<span className="nua-cadmin-nav-label">{c.label || c.name}</span>
|
|
167
|
+
<span className="nua-cadmin-nav-count">{c.entryCount}</span>
|
|
168
|
+
</button>
|
|
169
|
+
)
|
|
112
170
|
|
|
113
171
|
return (
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
172
|
+
<aside className="nua-cadmin-sidebar">
|
|
173
|
+
<div className="nua-cadmin-sidebar-head">
|
|
174
|
+
<span className="nua-cadmin-brand">Collections</span>
|
|
175
|
+
{onClose ? <button type="button" className="nua-cadmin-close" aria-label="Close" onClick={onClose}>×</button> : null}
|
|
176
|
+
</div>
|
|
177
|
+
<nav className="nua-cadmin-nav">
|
|
178
|
+
{loading ? <div className="nua-cadmin-nav-state">Loading…</div> : null}
|
|
179
|
+
{error
|
|
180
|
+
? <button type="button" className="nua-cadmin-nav-state nua-cadmin-nav-error" onClick={onReload}>Failed to load — retry</button>
|
|
181
|
+
: null}
|
|
182
|
+
{!loading && !error && collections.length === 0 ? <div className="nua-cadmin-nav-state">No content types</div> : null}
|
|
183
|
+
{!loading && !error
|
|
184
|
+
? topLevel.flatMap((c) => [item(c, 0), ...(childrenByParent.get(c.name) ?? []).map((child) => item(child, 1))])
|
|
185
|
+
: null}
|
|
186
|
+
</nav>
|
|
187
|
+
</aside>
|
|
129
188
|
)
|
|
130
189
|
}
|
|
131
190
|
|
|
@@ -243,100 +302,113 @@ export interface CollectionsAdminAppProps {
|
|
|
243
302
|
* prefix). In webmaster this is `/app/project/:slug/session/:sessionId/cms`.
|
|
244
303
|
*/
|
|
245
304
|
apiBase: string
|
|
246
|
-
/** Optional close affordance shown in the
|
|
305
|
+
/** Optional close affordance shown in the sidebar (e.g. to collapse the WM tab). */
|
|
247
306
|
onClose?: () => void
|
|
248
307
|
}
|
|
249
308
|
|
|
250
309
|
export function CollectionsAdminApp({ apiBase, onClose }: CollectionsAdminAppProps) {
|
|
251
310
|
const client = useMemo(() => createClient(apiBase), [apiBase])
|
|
252
|
-
const [state, setState] = useState<View>({ view: 'list' })
|
|
311
|
+
const [state, setState] = useState<View>(() => typeof window !== 'undefined' ? parseRoute(window.location.hash) : { view: 'list' })
|
|
312
|
+
|
|
313
|
+
// The hash is the source of truth for navigation: writing it fires `hashchange`,
|
|
314
|
+
// which syncs React state — so back/forward, reload and deep links all work.
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
const onHash = () => setState(parseRoute(window.location.hash))
|
|
317
|
+
window.addEventListener('hashchange', onHash)
|
|
318
|
+
onHash()
|
|
319
|
+
return () => window.removeEventListener('hashchange', onHash)
|
|
320
|
+
}, [])
|
|
321
|
+
|
|
322
|
+
const navigate = useCallback((view: View) => {
|
|
323
|
+
const hash = routeToHash(view)
|
|
324
|
+
if (typeof window !== 'undefined' && window.location.hash !== hash) {
|
|
325
|
+
window.location.hash = hash
|
|
326
|
+
} else {
|
|
327
|
+
setState(view)
|
|
328
|
+
}
|
|
329
|
+
}, [])
|
|
253
330
|
|
|
254
|
-
//
|
|
255
|
-
// at the root and pass the active one down.
|
|
331
|
+
// Collection definitions drive both the sidebar menu and the editor's field
|
|
332
|
+
// rendering; load them once at the root and pass the active one down.
|
|
256
333
|
const collectionsState = useAsync(() => client.getCollections(), [client])
|
|
257
334
|
const collections = collectionsState.data ?? []
|
|
258
335
|
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
const goCreate = useCallback((collection: string) => setState({ view: 'create', collection }), [])
|
|
336
|
+
const activeCollectionName = state.view !== 'list' ? state.collection : undefined
|
|
337
|
+
const activeCollection = activeCollectionName ? collections.find((c) => c.name === activeCollectionName) : undefined
|
|
338
|
+
const collectionLabel = activeCollection ? (activeCollection.label || activeCollection.name) : (activeCollectionName ?? '')
|
|
263
339
|
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const collectionLabel = activeCollection ? (activeCollection.label || activeCollection.name) : (state.view !== 'list' ? state.collection : '')
|
|
268
|
-
|
|
269
|
-
const isEntryView = state.view === 'detail' || state.view === 'create'
|
|
340
|
+
const goEntries = useCallback((collection: string) => navigate({ view: 'entries', collection }), [navigate])
|
|
341
|
+
const goDetail = useCallback((collection: string, slug: string) => navigate({ view: 'detail', collection, slug }), [navigate])
|
|
342
|
+
const goCreate = useCallback((collection: string) => navigate({ view: 'create', collection }), [navigate])
|
|
270
343
|
|
|
271
344
|
return (
|
|
272
345
|
<div className="nua-cadmin">
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
{
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
{
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
</
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
</div>
|
|
346
|
+
<Sidebar
|
|
347
|
+
collections={collections}
|
|
348
|
+
loading={collectionsState.loading}
|
|
349
|
+
error={collectionsState.error}
|
|
350
|
+
activeCollection={activeCollectionName}
|
|
351
|
+
onSelect={goEntries}
|
|
352
|
+
onReload={collectionsState.reload}
|
|
353
|
+
onClose={onClose}
|
|
354
|
+
/>
|
|
355
|
+
|
|
356
|
+
<main className="nua-cadmin-main">
|
|
357
|
+
<div className="nua-cadmin-header">
|
|
358
|
+
{state.view === 'detail' || state.view === 'create'
|
|
359
|
+
? <button type="button" className="nua-cadmin-back" onClick={() => goEntries(state.collection)}>← {collectionLabel}</button>
|
|
360
|
+
: null}
|
|
361
|
+
<h2 className="nua-cadmin-title">
|
|
362
|
+
{state.view === 'list' ? 'Content' : collectionLabel}
|
|
363
|
+
{state.view === 'detail' ? <span className="nua-cadmin-crumb">/ {state.slug}</span> : null}
|
|
364
|
+
{state.view === 'create' ? <span className="nua-cadmin-crumb">/ new entry</span> : null}
|
|
365
|
+
</h2>
|
|
366
|
+
<span className="nua-cadmin-spacer" />
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<div className="nua-cadmin-body">
|
|
370
|
+
{state.view === 'list'
|
|
371
|
+
? (collectionsState.error
|
|
372
|
+
? <ErrorState error={collectionsState.error} onRetry={collectionsState.reload} />
|
|
373
|
+
: <EmptyState label="Pick a content type from the left to browse its entries." />)
|
|
374
|
+
: null}
|
|
375
|
+
{state.view === 'entries'
|
|
376
|
+
? (
|
|
377
|
+
<EntriesTable
|
|
378
|
+
client={client}
|
|
379
|
+
collection={state.collection}
|
|
380
|
+
onOpen={(slug) => goDetail(state.collection, slug)}
|
|
381
|
+
onCreate={() => goCreate(state.collection)}
|
|
382
|
+
/>
|
|
383
|
+
)
|
|
384
|
+
: null}
|
|
385
|
+
{state.view === 'detail'
|
|
386
|
+
? (
|
|
387
|
+
<EntryEditor
|
|
388
|
+
// Remount the editor when the slug changes so its draft/baseHash reset cleanly.
|
|
389
|
+
key={`${state.collection}/${state.slug}`}
|
|
390
|
+
client={client}
|
|
391
|
+
definition={activeCollection}
|
|
392
|
+
collection={state.collection}
|
|
393
|
+
slug={state.slug}
|
|
394
|
+
onDeleted={() => goEntries(state.collection)}
|
|
395
|
+
onRenamed={(newSlug) => goDetail(state.collection, newSlug)}
|
|
396
|
+
/>
|
|
397
|
+
)
|
|
398
|
+
: null}
|
|
399
|
+
{state.view === 'create'
|
|
400
|
+
? (
|
|
401
|
+
<EntryCreate
|
|
402
|
+
client={client}
|
|
403
|
+
definition={activeCollection}
|
|
404
|
+
collection={state.collection}
|
|
405
|
+
onCreated={(slug) => goDetail(state.collection, slug)}
|
|
406
|
+
onCancel={() => goEntries(state.collection)}
|
|
407
|
+
/>
|
|
408
|
+
)
|
|
409
|
+
: null}
|
|
410
|
+
</div>
|
|
411
|
+
</main>
|
|
340
412
|
</div>
|
|
341
413
|
)
|
|
342
414
|
}
|