@nuasite/collections-admin 0.43.0-beta.3 → 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/src/app.tsx CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
- * collections-admin SPA (cms-headless F3.1 read-only + F3.2 editing).
2
+ * collections-admin SPA a standalone CMS admin.
3
3
  *
4
- * Host-agnostic: driven only by an `apiBase` prop, with internal view-state
5
- * navigation (list entries editor/create) via React state never the host
6
- * router. That keeps the same component usable as a webmaster tab today and at
7
- * `/_nua/admin` for local dev later (F7).
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
11
16
  * optimistic save and `409` conflict resolution, plus create/delete/rename flows.
12
17
  */
13
18
 
19
+ import { type CmsClient, CmsClientError, createClient } from '@nuasite/cms-client'
14
20
  import type { CollectionDefinition, CollectionEntryInfo } from '@nuasite/cms-types'
15
21
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
- import { type CmsClient, CmsClientError, createClient } from './client'
17
22
  import { EntryCreate } from './entry-create'
18
23
  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
- // Collection list
134
+ // Sidebar — content-type menu
104
135
  // ============================================================================
105
136
 
106
- function CollectionList({ client, onOpen }: { client: CmsClient; onOpen: (collection: string) => void }) {
107
- const { data, error, loading, reload } = useAsync(() => client.getCollections(), [client])
108
-
109
- if (loading) return <Spinner label="Loading collections…" />
110
- if (error) return <ErrorState error={error} onRetry={reload} />
111
- if (!data || data.length === 0) return <EmptyState label="No collections found in this project." />
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
- <div className="nua-cadmin-list">
115
- {data.map((collection) => (
116
- <button key={collection.name} type="button" className="nua-cadmin-card" onClick={() => onOpen(collection.name)}>
117
- <span className="nua-cadmin-card-main">
118
- <span className="nua-cadmin-card-label">{collection.label || collection.name}</span>
119
- <span className="nua-cadmin-card-sub">
120
- {collection.name}
121
- {collection.type ? ` · ${collection.type}` : ''}
122
- {` · ${collection.fileExtension}`}
123
- </span>
124
- </span>
125
- <span className="nua-cadmin-badge">{collection.entryCount} {collection.entryCount === 1 ? 'entry' : 'entries'}</span>
126
- </button>
127
- ))}
128
- </div>
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 header (e.g. to collapse the WM tab). */
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
- // The collection definitions drive the editor's field rendering; load them once
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 goList = useCallback(() => setState({ view: 'list' }), [])
260
- const goEntries = useCallback((collection: string) => setState({ view: 'entries', collection }), [])
261
- const goDetail = useCallback((collection: string, slug: string) => setState({ view: 'detail', collection, slug }), [])
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 activeCollection = state.view !== 'list'
265
- ? collections.find((c) => c.name === state.collection)
266
- : undefined
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
- <div className="nua-cadmin-header">
274
- {state.view === 'entries' ? <button type="button" className="nua-cadmin-back" onClick={goList}>← Collections</button> : null}
275
- {isEntryView
276
- ? <button type="button" className="nua-cadmin-back" onClick={() => goEntries(state.collection)}>← {collectionLabel}</button>
277
- : null}
278
-
279
- {state.view === 'list' ? <h2 className="nua-cadmin-title">Collections</h2> : null}
280
- {state.view === 'entries' ? <h2 className="nua-cadmin-title">{collectionLabel}</h2> : null}
281
- {state.view === 'detail'
282
- ? (
283
- <h2 className="nua-cadmin-title">
284
- {collectionLabel}
285
- <span className="nua-cadmin-crumb">/ {state.slug}</span>
286
- </h2>
287
- )
288
- : null}
289
- {state.view === 'create'
290
- ? (
291
- <h2 className="nua-cadmin-title">
292
- {collectionLabel}
293
- <span className="nua-cadmin-crumb">/ new entry</span>
294
- </h2>
295
- )
296
- : null}
297
-
298
- <span className="nua-cadmin-spacer" />
299
- {onClose ? <button type="button" className="nua-cadmin-close" aria-label="Close" onClick={onClose}>×</button> : null}
300
- </div>
301
-
302
- <div className="nua-cadmin-body">
303
- {state.view === 'list' ? <CollectionList client={client} onOpen={goEntries} /> : null}
304
- {state.view === 'entries'
305
- ? (
306
- <EntriesTable
307
- client={client}
308
- collection={state.collection}
309
- onOpen={(slug) => goDetail(state.collection, slug)}
310
- onCreate={() => goCreate(state.collection)}
311
- />
312
- )
313
- : null}
314
- {state.view === 'detail'
315
- ? (
316
- <EntryEditor
317
- // Remount the editor when the slug changes so its draft/baseHash reset cleanly.
318
- key={`${state.collection}/${state.slug}`}
319
- client={client}
320
- definition={activeCollection}
321
- collection={state.collection}
322
- slug={state.slug}
323
- onDeleted={() => goEntries(state.collection)}
324
- onRenamed={(newSlug) => goDetail(state.collection, newSlug)}
325
- />
326
- )
327
- : null}
328
- {state.view === 'create'
329
- ? (
330
- <EntryCreate
331
- client={client}
332
- definition={activeCollection}
333
- collection={state.collection}
334
- onCreated={(slug) => goDetail(state.collection, slug)}
335
- onCancel={() => goEntries(state.collection)}
336
- />
337
- )
338
- : null}
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
  }
@@ -7,11 +7,10 @@
7
7
  * native draft model as the editor.
8
8
  */
9
9
 
10
+ import { type CmsClient, CmsClientError, draftForCreate, type EntryDraft, setDraftField } from '@nuasite/cms-client'
10
11
  import type { CollectionDefinition, FieldDefinition } from '@nuasite/cms-types'
11
12
  import { useCallback, useMemo, useState } from 'react'
12
- import { type CmsClient, CmsClientError } from './client'
13
13
  import { type EditorContext, FieldEditor } from './field-editor'
14
- import { draftForCreate, type EntryDraft, setDraftField } from './form-model'
15
14
 
16
15
  function visibleFields(fields: FieldDefinition[]): FieldDefinition[] {
17
16
  return fields.filter(f => !f.hidden)