@nuasite/collections-admin 0.43.0-beta.1 → 0.43.0-beta.3

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/styles.css CHANGED
@@ -16,6 +16,7 @@
16
16
  --nua-cadmin-danger: #b91c1c;
17
17
  --nua-cadmin-danger-bg: #fef2f2;
18
18
 
19
+ position: relative;
19
20
  display: flex;
20
21
  flex-direction: column;
21
22
  height: 100%;
@@ -369,3 +370,383 @@
369
370
  border: 1px solid var(--nua-cadmin-border);
370
371
  display: block;
371
372
  }
373
+
374
+ /* --- Editor: form controls --- */
375
+
376
+ .nua-cadmin-input,
377
+ .nua-cadmin-textarea,
378
+ .nua-cadmin-body-editor,
379
+ select.nua-cadmin-input {
380
+ width: 100%;
381
+ padding: 7px 10px;
382
+ border: 1px solid var(--nua-cadmin-border);
383
+ border-radius: 6px;
384
+ background: var(--nua-cadmin-bg);
385
+ color: var(--nua-cadmin-fg);
386
+ font-family: inherit;
387
+ font-size: 13px;
388
+ line-height: 1.5;
389
+ }
390
+
391
+ .nua-cadmin-input:focus,
392
+ .nua-cadmin-textarea:focus,
393
+ .nua-cadmin-body-editor:focus,
394
+ select.nua-cadmin-input:focus {
395
+ outline: none;
396
+ border-color: var(--nua-cadmin-accent);
397
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
398
+ }
399
+
400
+ .nua-cadmin-textarea,
401
+ .nua-cadmin-body-editor {
402
+ resize: vertical;
403
+ min-height: 64px;
404
+ }
405
+
406
+ .nua-cadmin-body-editor {
407
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
408
+ font-size: 12px;
409
+ }
410
+
411
+ .nua-cadmin-field-loading {
412
+ padding: 6px 0;
413
+ color: var(--nua-cadmin-muted);
414
+ font-style: italic;
415
+ }
416
+
417
+ /* --- Boolean toggle --- */
418
+
419
+ .nua-cadmin-toggle {
420
+ display: inline-flex;
421
+ align-items: center;
422
+ gap: 8px;
423
+ padding: 4px;
424
+ padding-right: 10px;
425
+ border: 1px solid var(--nua-cadmin-border);
426
+ border-radius: 999px;
427
+ background: var(--nua-cadmin-bg);
428
+ cursor: pointer;
429
+ color: var(--nua-cadmin-muted);
430
+ font-size: 12px;
431
+ }
432
+
433
+ .nua-cadmin-toggle-knob {
434
+ width: 22px;
435
+ height: 22px;
436
+ border-radius: 50%;
437
+ background: var(--nua-cadmin-border);
438
+ transition: background 0.15s, transform 0.15s;
439
+ }
440
+
441
+ .nua-cadmin-toggle-on {
442
+ border-color: var(--nua-cadmin-accent);
443
+ color: var(--nua-cadmin-accent);
444
+ }
445
+
446
+ .nua-cadmin-toggle-on .nua-cadmin-toggle-knob {
447
+ background: var(--nua-cadmin-accent);
448
+ transform: translateX(2px);
449
+ }
450
+
451
+ .nua-cadmin-toggle-publish {
452
+ font-weight: 600;
453
+ }
454
+
455
+ /* --- Array repeater --- */
456
+
457
+ .nua-cadmin-array {
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 6px;
461
+ }
462
+
463
+ .nua-cadmin-array-item {
464
+ display: flex;
465
+ align-items: flex-start;
466
+ gap: 6px;
467
+ }
468
+
469
+ .nua-cadmin-array-item-body {
470
+ flex: 1;
471
+ min-width: 0;
472
+ }
473
+
474
+ .nua-cadmin-icon-btn {
475
+ flex-shrink: 0;
476
+ width: 28px;
477
+ height: 28px;
478
+ border: 1px solid var(--nua-cadmin-border);
479
+ border-radius: 6px;
480
+ background: var(--nua-cadmin-bg);
481
+ color: var(--nua-cadmin-muted);
482
+ cursor: pointer;
483
+ font-size: 15px;
484
+ line-height: 1;
485
+ }
486
+
487
+ .nua-cadmin-icon-btn:hover {
488
+ border-color: var(--nua-cadmin-danger);
489
+ color: var(--nua-cadmin-danger);
490
+ }
491
+
492
+ .nua-cadmin-add-btn {
493
+ align-self: flex-start;
494
+ padding: 5px 10px;
495
+ border: 1px dashed var(--nua-cadmin-border);
496
+ border-radius: 6px;
497
+ background: var(--nua-cadmin-bg);
498
+ color: var(--nua-cadmin-accent);
499
+ font-size: 12px;
500
+ cursor: pointer;
501
+ }
502
+
503
+ .nua-cadmin-add-btn:hover:not(:disabled) {
504
+ border-color: var(--nua-cadmin-accent);
505
+ background: var(--nua-cadmin-bg-subtle);
506
+ }
507
+
508
+ .nua-cadmin-add-btn:disabled {
509
+ opacity: 0.5;
510
+ cursor: default;
511
+ }
512
+
513
+ /* --- Nested object group --- */
514
+
515
+ .nua-cadmin-object {
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: 8px;
519
+ padding: 8px 10px;
520
+ border: 1px solid var(--nua-cadmin-border);
521
+ border-radius: 8px;
522
+ background: var(--nua-cadmin-bg-subtle);
523
+ }
524
+
525
+ .nua-cadmin-object-field {
526
+ display: flex;
527
+ flex-direction: column;
528
+ gap: 3px;
529
+ }
530
+
531
+ /* --- Media picker --- */
532
+
533
+ .nua-cadmin-media {
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 6px;
537
+ }
538
+
539
+ .nua-cadmin-media-row {
540
+ display: flex;
541
+ align-items: center;
542
+ gap: 6px;
543
+ }
544
+
545
+ .nua-cadmin-media-hint {
546
+ color: var(--nua-cadmin-muted);
547
+ font-size: 12px;
548
+ font-style: italic;
549
+ }
550
+
551
+ .nua-cadmin-media-error {
552
+ color: var(--nua-cadmin-danger);
553
+ font-size: 12px;
554
+ }
555
+
556
+ .nua-cadmin-file-input {
557
+ display: none;
558
+ }
559
+
560
+ /* --- Editor layout --- */
561
+
562
+ .nua-cadmin-editor {
563
+ display: flex;
564
+ flex-direction: column;
565
+ gap: 12px;
566
+ }
567
+
568
+ .nua-cadmin-editor-toolbar {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: 8px;
572
+ }
573
+
574
+ .nua-cadmin-entries-toolbar {
575
+ display: flex;
576
+ justify-content: flex-end;
577
+ margin-bottom: 10px;
578
+ }
579
+
580
+ .nua-cadmin-editor-header-fields {
581
+ display: flex;
582
+ flex-direction: column;
583
+ gap: 10px;
584
+ padding-bottom: 12px;
585
+ border-bottom: 1px solid var(--nua-cadmin-border);
586
+ }
587
+
588
+ .nua-cadmin-editor-grid {
589
+ display: grid;
590
+ grid-template-columns: 1fr;
591
+ gap: 18px;
592
+ }
593
+
594
+ @media (min-width: 720px) {
595
+ .nua-cadmin-editor-grid:has(.nua-cadmin-editor-sidebar) {
596
+ grid-template-columns: minmax(0, 1fr) 240px;
597
+ }
598
+ }
599
+
600
+ .nua-cadmin-editor-main,
601
+ .nua-cadmin-editor-sidebar {
602
+ display: flex;
603
+ flex-direction: column;
604
+ gap: 12px;
605
+ min-width: 0;
606
+ }
607
+
608
+ .nua-cadmin-editor-sidebar {
609
+ padding: 12px;
610
+ border: 1px solid var(--nua-cadmin-border);
611
+ border-radius: 8px;
612
+ background: var(--nua-cadmin-bg-subtle);
613
+ align-self: start;
614
+ }
615
+
616
+ .nua-cadmin-field-publish-toggle,
617
+ .nua-cadmin-field-publish-date {
618
+ padding-bottom: 10px;
619
+ border-bottom: 1px solid var(--nua-cadmin-border);
620
+ }
621
+
622
+ .nua-cadmin-group-title {
623
+ margin: 8px 0 2px;
624
+ font-size: 11px;
625
+ font-weight: 700;
626
+ text-transform: uppercase;
627
+ letter-spacing: 0.04em;
628
+ color: var(--nua-cadmin-muted);
629
+ }
630
+
631
+ /* --- Buttons --- */
632
+
633
+ .nua-cadmin-btn {
634
+ padding: 6px 12px;
635
+ border: 1px solid var(--nua-cadmin-border);
636
+ border-radius: 6px;
637
+ background: var(--nua-cadmin-bg);
638
+ color: var(--nua-cadmin-fg);
639
+ font-size: 12px;
640
+ font-weight: 500;
641
+ cursor: pointer;
642
+ }
643
+
644
+ .nua-cadmin-btn:hover:not(:disabled) {
645
+ background: var(--nua-cadmin-bg-subtle);
646
+ }
647
+
648
+ .nua-cadmin-btn:disabled {
649
+ opacity: 0.5;
650
+ cursor: default;
651
+ }
652
+
653
+ .nua-cadmin-btn-primary {
654
+ border-color: var(--nua-cadmin-accent);
655
+ background: var(--nua-cadmin-accent);
656
+ color: #ffffff;
657
+ }
658
+
659
+ .nua-cadmin-btn-primary:hover:not(:disabled) {
660
+ background: #1d4ed8;
661
+ }
662
+
663
+ .nua-cadmin-btn-danger {
664
+ border-color: #fecaca;
665
+ color: var(--nua-cadmin-danger);
666
+ }
667
+
668
+ .nua-cadmin-btn-danger:hover:not(:disabled) {
669
+ background: var(--nua-cadmin-danger-bg);
670
+ }
671
+
672
+ .nua-cadmin-btn-ghost {
673
+ border-color: transparent;
674
+ background: transparent;
675
+ color: var(--nua-cadmin-muted);
676
+ }
677
+
678
+ .nua-cadmin-btn-ghost:hover:not(:disabled) {
679
+ background: var(--nua-cadmin-bg-subtle);
680
+ color: var(--nua-cadmin-fg);
681
+ }
682
+
683
+ /* --- Save status badge --- */
684
+
685
+ .nua-cadmin-status {
686
+ display: inline-flex;
687
+ align-items: center;
688
+ padding: 3px 9px;
689
+ border-radius: 999px;
690
+ font-size: 11px;
691
+ font-weight: 600;
692
+ }
693
+
694
+ .nua-cadmin-status-saving {
695
+ background: var(--nua-cadmin-bg-subtle);
696
+ color: var(--nua-cadmin-muted);
697
+ }
698
+
699
+ .nua-cadmin-status-saved {
700
+ background: #ecfdf5;
701
+ color: #166534;
702
+ }
703
+
704
+ .nua-cadmin-status-conflict {
705
+ background: #fffbeb;
706
+ color: #92400e;
707
+ }
708
+
709
+ .nua-cadmin-status-error {
710
+ background: var(--nua-cadmin-danger-bg);
711
+ color: var(--nua-cadmin-danger);
712
+ }
713
+
714
+ /* --- Conflict dialog --- */
715
+
716
+ .nua-cadmin-dialog-backdrop {
717
+ position: absolute;
718
+ inset: 0;
719
+ display: flex;
720
+ align-items: center;
721
+ justify-content: center;
722
+ padding: 24px;
723
+ background: rgba(17, 24, 39, 0.4);
724
+ z-index: 10;
725
+ }
726
+
727
+ .nua-cadmin-dialog {
728
+ width: 100%;
729
+ max-width: 420px;
730
+ padding: 18px;
731
+ border-radius: 10px;
732
+ background: var(--nua-cadmin-bg);
733
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
734
+ }
735
+
736
+ .nua-cadmin-dialog-title {
737
+ font-size: 14px;
738
+ font-weight: 700;
739
+ margin-bottom: 8px;
740
+ }
741
+
742
+ .nua-cadmin-dialog-body {
743
+ color: var(--nua-cadmin-muted);
744
+ margin-bottom: 16px;
745
+ }
746
+
747
+ .nua-cadmin-dialog-actions {
748
+ display: flex;
749
+ flex-wrap: wrap;
750
+ gap: 8px;
751
+ justify-content: flex-end;
752
+ }
package/dist/types/app.js DELETED
@@ -1,240 +0,0 @@
1
- /**
2
- * collections-admin SPA — read-only milestone (cms-headless F3.1).
3
- *
4
- * Host-agnostic: driven only by an `apiBase` prop, with internal view-state
5
- * navigation (list → entries → detail) via React state — never the host router.
6
- * That keeps the same component usable as a webmaster tab today and at
7
- * `/_nua/admin` for local dev later (F7).
8
- *
9
- * Read-only: browse collections, list entries (sparse projection + cursor
10
- * pagination), and view a single entry's frontmatter + markdown body. Mutations
11
- * (editor/media/conflict) arrive in F3.2.
12
- */
13
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
- import { CmsClientError, createClient } from './client';
15
- import { FieldRow } from './field-view';
16
- import './styles.css';
17
- function useAsync(load, deps) {
18
- const [state, setState] = useState({ data: null, error: null, loading: true });
19
- const [nonce, setNonce] = useState(0);
20
- const loadRef = useRef(load);
21
- loadRef.current = load;
22
- // `load` is read through a ref; re-runs are driven by the explicit `deps` and
23
- // the reload `nonce` so the effect deps stay stable and lint-clean.
24
- const effectDeps = [...deps, nonce];
25
- useEffect(() => {
26
- let active = true;
27
- setState({ data: null, error: null, loading: true });
28
- loadRef.current().then((data) => {
29
- if (active)
30
- setState({ data, error: null, loading: false });
31
- }, (error) => {
32
- if (active)
33
- setState({ data: null, error: error instanceof Error ? error : new Error(String(error)), loading: false });
34
- });
35
- return () => {
36
- active = false;
37
- };
38
- }, effectDeps);
39
- const reload = useCallback(() => setNonce((n) => n + 1), []);
40
- return { ...state, reload };
41
- }
42
- // ============================================================================
43
- // Presentational primitives
44
- // ============================================================================
45
- function Spinner({ label }) {
46
- return (<div className="nua-cadmin-state">
47
- <div className="nua-cadmin-spinner"/>
48
- <div>{label}</div>
49
- </div>);
50
- }
51
- function ErrorState({ error, onRetry }) {
52
- const title = error instanceof CmsClientError && error.isUnauthorized
53
- ? 'Session expired'
54
- : error instanceof CmsClientError && error.isForbidden
55
- ? 'No access'
56
- : 'Something went wrong';
57
- return (<div className="nua-cadmin-error">
58
- <div className="nua-cadmin-error-title">{title}</div>
59
- <div>{error.message}</div>
60
- {onRetry ? <button type="button" className="nua-cadmin-retry" onClick={onRetry}>Try again</button> : null}
61
- </div>);
62
- }
63
- function EmptyState({ label }) {
64
- return <div className="nua-cadmin-state">{label}</div>;
65
- }
66
- // ============================================================================
67
- // Collection list
68
- // ============================================================================
69
- function CollectionList({ client, onOpen }) {
70
- const { data, error, loading, reload } = useAsync(() => client.getCollections(), [client]);
71
- if (loading)
72
- return <Spinner label="Loading collections…"/>;
73
- if (error)
74
- return <ErrorState error={error} onRetry={reload}/>;
75
- if (!data || data.length === 0)
76
- return <EmptyState label="No collections found in this project."/>;
77
- return (<div className="nua-cadmin-list">
78
- {data.map((collection) => (<button key={collection.name} type="button" className="nua-cadmin-card" onClick={() => onOpen(collection.name)}>
79
- <span className="nua-cadmin-card-main">
80
- <span className="nua-cadmin-card-label">{collection.label || collection.name}</span>
81
- <span className="nua-cadmin-card-sub">
82
- {collection.name}
83
- {collection.type ? ` · ${collection.type}` : ''}
84
- {` · ${collection.fileExtension}`}
85
- </span>
86
- </span>
87
- <span className="nua-cadmin-badge">{collection.entryCount} {collection.entryCount === 1 ? 'entry' : 'entries'}</span>
88
- </button>))}
89
- </div>);
90
- }
91
- // ============================================================================
92
- // Entries table (sparse projection + cursor pagination)
93
- // ============================================================================
94
- const ENTRIES_PAGE_SIZE = 50;
95
- const ENTRIES_FIELDS = 'slug,title,draft,pathname';
96
- function EntriesTable({ client, collection, onOpen }) {
97
- const [rows, setRows] = useState([]);
98
- const [cursor, setCursor] = useState(undefined);
99
- const [hasMore, setHasMore] = useState(false);
100
- const [error, setError] = useState(null);
101
- const [loading, setLoading] = useState(true);
102
- const [loadingMore, setLoadingMore] = useState(false);
103
- const loadPage = useCallback(async (nextCursor, append) => {
104
- if (append)
105
- setLoadingMore(true);
106
- else
107
- setLoading(true);
108
- setError(null);
109
- try {
110
- const result = await client.getEntries(collection, {
111
- fields: ENTRIES_FIELDS,
112
- draft: 'all',
113
- limit: ENTRIES_PAGE_SIZE,
114
- cursor: nextCursor,
115
- });
116
- setRows((prev) => (append ? [...prev, ...result.entries] : result.entries));
117
- setCursor(result.cursor);
118
- setHasMore(result.hasMore);
119
- }
120
- catch (e) {
121
- setError(e instanceof Error ? e : new Error(String(e)));
122
- }
123
- finally {
124
- setLoading(false);
125
- setLoadingMore(false);
126
- }
127
- }, [client, collection]);
128
- useEffect(() => {
129
- setRows([]);
130
- setCursor(undefined);
131
- setHasMore(false);
132
- void loadPage(undefined, false);
133
- }, [loadPage]);
134
- if (loading)
135
- return <Spinner label="Loading entries…"/>;
136
- if (error)
137
- return <ErrorState error={error} onRetry={() => void loadPage(undefined, false)}/>;
138
- if (rows.length === 0)
139
- return <EmptyState label="This collection has no entries."/>;
140
- return (<div>
141
- <table className="nua-cadmin-table">
142
- <thead>
143
- <tr>
144
- <th>Slug</th>
145
- <th>Title</th>
146
- <th>Draft</th>
147
- <th>Pathname</th>
148
- </tr>
149
- </thead>
150
- <tbody>
151
- {rows.map((entry) => (<tr key={entry.slug} className="nua-cadmin-row" onClick={() => onOpen(entry.slug)}>
152
- <td className="nua-cadmin-cell-mono">{entry.slug}</td>
153
- <td>{entry.title ?? <span className="nua-cadmin-field-empty">—</span>}</td>
154
- <td>{entry.draft ? <span className="nua-cadmin-badge nua-cadmin-badge-draft">draft</span> : ''}</td>
155
- <td className="nua-cadmin-cell-mono">{entry.pathname ?? '—'}</td>
156
- </tr>))}
157
- </tbody>
158
- </table>
159
- {hasMore ? (<button type="button" className="nua-cadmin-load-more" disabled={loadingMore} onClick={() => void loadPage(cursor, true)}>
160
- {loadingMore ? 'Loading…' : 'Load more'}
161
- </button>) : null}
162
- </div>);
163
- }
164
- // ============================================================================
165
- // Entry detail
166
- // ============================================================================
167
- /**
168
- * Order the collection's fields for display: `publish-toggle`/`publish-date`
169
- * roles and `sidebar`/`header` positioned fields first, then the rest in schema
170
- * order. Hidden fields are dropped.
171
- */
172
- function orderFields(fields) {
173
- const visible = fields.filter((f) => !f.hidden);
174
- const pinned = visible.filter((f) => f.role !== undefined || f.position !== undefined);
175
- const rest = visible.filter((f) => f.role === undefined && f.position === undefined);
176
- return [...pinned, ...rest];
177
- }
178
- function EntryDetail({ client, collections, collection, slug }) {
179
- const { data, error, loading, reload } = useAsync(() => client.getEntry(collection, slug), [client, collection, slug]);
180
- const def = useMemo(() => collections.find((c) => c.name === collection), [collections, collection]);
181
- if (loading)
182
- return <Spinner label="Loading entry…"/>;
183
- if (error)
184
- return <ErrorState error={error} onRetry={reload}/>;
185
- if (!data)
186
- return <EmptyState label="Entry not found."/>;
187
- const fieldDefs = def ? orderFields(def.fields) : [];
188
- const renderedNames = new Set(fieldDefs.map((f) => f.name));
189
- // Frontmatter keys present on the entry but absent from the inferred schema.
190
- const extraKeys = Object.keys(data.frontmatter).filter((k) => !renderedNames.has(k));
191
- return (<div>
192
- <div className="nua-cadmin-fields">
193
- {fieldDefs.length === 0 && extraKeys.length === 0 ? <EmptyState label="No frontmatter fields."/> : null}
194
- {fieldDefs.map((field) => (<FieldRow key={field.name} field={field} raw={data.frontmatter[field.name]?.value}/>))}
195
- {extraKeys.map((key) => (<FieldRow key={key} field={{ name: key, type: 'text', required: false }} raw={data.frontmatter[key]?.value}/>))}
196
- </div>
197
-
198
- <h3 className="nua-cadmin-section-title">Body</h3>
199
- {data.body.trim() === ''
200
- ? <EmptyState label="This entry has no markdown body."/>
201
- : <pre className="nua-cadmin-body-content">{data.body}</pre>}
202
- </div>);
203
- }
204
- export function CollectionsAdminApp({ apiBase, onClose }) {
205
- const client = useMemo(() => createClient(apiBase), [apiBase]);
206
- const [state, setState] = useState({ view: 'list' });
207
- // The collection definitions are needed by the detail view to drive field
208
- // rendering; load them once at the root and pass down.
209
- const collectionsState = useAsync(() => client.getCollections(), [client]);
210
- const collections = collectionsState.data ?? [];
211
- const goList = useCallback(() => setState({ view: 'list' }), []);
212
- const goEntries = useCallback((collection) => setState({ view: 'entries', collection }), []);
213
- const goDetail = useCallback((collection, slug) => setState({ view: 'detail', collection, slug }), []);
214
- const activeCollection = state.view !== 'list'
215
- ? collections.find((c) => c.name === state.collection)
216
- : undefined;
217
- const collectionLabel = activeCollection ? (activeCollection.label || activeCollection.name) : (state.view !== 'list' ? state.collection : '');
218
- return (<div className="nua-cadmin">
219
- <div className="nua-cadmin-header">
220
- {state.view === 'entries' ? (<button type="button" className="nua-cadmin-back" onClick={goList}>← Collections</button>) : null}
221
- {state.view === 'detail' ? (<button type="button" className="nua-cadmin-back" onClick={() => goEntries(state.collection)}>← {collectionLabel}</button>) : null}
222
-
223
- {state.view === 'list' ? <h2 className="nua-cadmin-title">Collections</h2> : null}
224
- {state.view === 'entries' ? <h2 className="nua-cadmin-title">{collectionLabel}</h2> : null}
225
- {state.view === 'detail' ? (<h2 className="nua-cadmin-title">
226
- {collectionLabel}
227
- <span className="nua-cadmin-crumb"> / {state.slug}</span>
228
- </h2>) : null}
229
-
230
- <span className="nua-cadmin-spacer"/>
231
- {onClose ? <button type="button" className="nua-cadmin-close" aria-label="Close" onClick={onClose}>×</button> : null}
232
- </div>
233
-
234
- <div className="nua-cadmin-body">
235
- {state.view === 'list' ? <CollectionList client={client} onOpen={goEntries}/> : null}
236
- {state.view === 'entries' ? <EntriesTable client={client} collection={state.collection} onOpen={(slug) => goDetail(state.collection, slug)}/> : null}
237
- {state.view === 'detail' ? <EntryDetail client={client} collections={collections} collection={state.collection} slug={state.slug}/> : null}
238
- </div>
239
- </div>);
240
- }