@quoin-cms/admin 0.3.0 → 0.5.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/index.html ADDED
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/admin/favicon.ico" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <title>Quoin Admin</title>
14
+ </head>
15
+ <body>
16
+ <div id="app"></div>
17
+ <script type="module" src="/src/main.ts"></script>
18
+ </body>
19
+ </html>
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@quoin-cms/admin",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
7
- "src"
7
+ "src",
8
+ "index.html"
8
9
  ],
9
10
  "description": "Quoin CMS admin — Svelte 5 SPA",
10
11
  "license": "AGPL-3.0-or-later",
@@ -55,11 +55,11 @@
55
55
  {#if overridePath}
56
56
  {#if overrideComponent}
57
57
  {@const Override = overrideComponent}
58
- <Override {...rest as TProps} />
58
+ <Override {...rest as unknown as TProps} />
59
59
  {:else if loadError}
60
60
  <!-- Dev-only error render; in prod the console error suffices. -->
61
61
  <span class="text-xs text-red-500" title={loadError}>slot load error: {name}</span>
62
62
  {/if}
63
63
  {:else if children}
64
- {@render children(rest as TProps)}
64
+ {@render children(rest as unknown as TProps)}
65
65
  {/if}
@@ -14,7 +14,7 @@ import {
14
14
  Mail, StickyNote, Menu, Image, FolderOpen, Tags,
15
15
  MessageSquare, BarChart3, MousePointerClick, Puzzle, LayoutDashboard, User, type Icon
16
16
  } from 'lucide-svelte'
17
- import type { Component } from 'svelte'
17
+ import type { Component, ComponentType, SvelteComponent } from 'svelte'
18
18
 
19
19
  // Consumer-declared custom pages from admin.config.ts. Sidebar auto-renders
20
20
  // nav entries for pages with a `nav` block; omit `nav` on a page to hide it
@@ -31,7 +31,7 @@ const customPagesByGroup = $derived.by(() => {
31
31
  return groups
32
32
  })
33
33
 
34
- const collectionIcons: Record<string, Component> = {
34
+ const collectionIcons: Record<string, ComponentType<SvelteComponent<any>> | Component<any>> = {
35
35
  posts: PenLine,
36
36
  authors: Users,
37
37
  categories: FolderTree,
@@ -50,7 +50,7 @@ const collectionIcons: Record<string, Component> = {
50
50
  ad_clicks: MousePointerClick,
51
51
  }
52
52
 
53
- function getIcon(key: string): Component {
53
+ function getIcon(key: string): ComponentType<SvelteComponent<any>> | Component<any> {
54
54
  return collectionIcons[key] || FileText
55
55
  }
56
56
 
@@ -70,6 +70,7 @@ let {
70
70
  let grouped = $derived.by(() => {
71
71
  const groups: Record<string, CollectionSchema[]> = {}
72
72
  for (const col of collections) {
73
+ if (col.admin?.hidden) continue // omitted from nav; still reachable by URL / API
73
74
  const group = col.admin?.group || 'Collections'
74
75
  if (!groups[group]) groups[group] = []
75
76
  groups[group].push(col)
@@ -5,7 +5,7 @@ import ApiView from './doc/ApiView.svelte'
5
5
  import VersionHistory from './doc/VersionHistory.svelte'
6
6
  import ScheduleModal from './doc/ScheduleModal.svelte'
7
7
  import DynamicForm from './DynamicForm.svelte'
8
- import { resolveFieldComponent } from './fields/index.js'
8
+ import FieldWidget from './fields/FieldWidget.svelte'
9
9
  import { formatFileSize } from '$lib/utils/format.js'
10
10
  import { useDirtyState } from '$lib/utils/dirty.svelte.js'
11
11
  import { unpublishRecord } from '$lib/api/records.js'
@@ -262,10 +262,12 @@ function handleSchedule(isoDate: string) {
262
262
  {#if sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id)}
263
263
  <aside class="space-y-5 lg:sticky lg:top-[260px] lg:self-start">
264
264
  {#each sideFields as f (f.name)}
265
- {@const FieldComponent = resolveFieldComponent(f)}
266
- {#if FieldComponent}
267
- <FieldComponent field={f} bind:value={formData[f.name]} error={errors[f.name]} {formData} />
268
- {/if}
265
+ <FieldWidget
266
+ field={f}
267
+ bind:value={formData[f.name]}
268
+ error={errors[f.name]}
269
+ {formData}
270
+ />
269
271
  {/each}
270
272
 
271
273
  {#if collection.versions && mode === 'edit' && record?.id}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { FieldSchema } from '$lib/types/schema.js'
3
- import { resolveFieldComponent } from './fields/index.js'
3
+ import FieldWidget from './fields/FieldWidget.svelte'
4
4
 
5
5
  let {
6
6
  fields,
@@ -27,22 +27,15 @@ let mainFields = $derived(
27
27
 
28
28
  // formData is owned by the parent (DocumentEditLayout) and bound in.
29
29
  // DynamicForm is now a pure renderer.
30
- let localErrors = $state<Record<string, string>>({})
31
- let mergedErrors = $derived({ ...errors, ...localErrors })
32
30
  </script>
33
31
 
34
32
  {#snippet fieldRenderer(f: FieldSchema)}
35
- {@const FieldComponent = resolveFieldComponent(f)}
36
- {#if FieldComponent}
37
- <FieldComponent
38
- field={f}
39
- bind:value={formData[f.name]}
40
- error={mergedErrors[f.name]}
41
- {formData}
42
- />
43
- {:else}
44
- <p class="text-sm text-muted-foreground">Unknown field type: {f.type}</p>
45
- {/if}
33
+ <FieldWidget
34
+ field={f}
35
+ bind:value={formData[f.name]}
36
+ error={errors[f.name]}
37
+ {formData}
38
+ />
46
39
  {/snippet}
47
40
 
48
41
  <div class="space-y-6">
@@ -3,6 +3,7 @@ import { listRecords } from '$lib/api/records.js';
3
3
  import { uploadFile } from '$lib/api/files.js';
4
4
  import { createRecord } from '$lib/api/records.js';
5
5
  import { formatFileSize } from '$lib/utils/format.js';
6
+ import { untrack } from 'svelte';
6
7
  import { X, Upload, Search, FolderOpen, FileText, Check } from 'lucide-svelte';
7
8
 
8
9
  interface MediaItem {
@@ -166,14 +167,20 @@ function getThumbnailUrl(item: any): string {
166
167
  return item.file?.url || item.url || '';
167
168
  }
168
169
 
170
+ // Reset + load only when the modal opens. Wrap the body in untrack so the
171
+ // state it reads (page/search/selectedFolder via loadMedia) doesn't become a
172
+ // dependency — otherwise paginating (page++) would re-run this and reset to
173
+ // page 1, breaking pagination entirely.
169
174
  $effect(() => {
170
175
  if (open) {
171
- page = 1;
172
- search = '';
173
- selectedFolder = null;
174
- selected = new Map();
175
- loadMedia();
176
- loadFolders();
176
+ untrack(() => {
177
+ page = 1;
178
+ search = '';
179
+ selectedFolder = null;
180
+ selected = new Map();
181
+ loadMedia();
182
+ loadFolders();
183
+ });
177
184
  }
178
185
  });
179
186
  </script>
@@ -0,0 +1,162 @@
1
+ <script lang="ts">
2
+ // The record "More actions" dropdown shown in the edit header. Self-contained:
3
+ // owns its open state, the delete-confirm dialog, and the duplicate/delete API
4
+ // calls + post-action navigation. Disabled in create mode (no record yet).
5
+ import { MoreVertical, Copy, CopyPlus, Trash2 } from 'lucide-svelte'
6
+ import { goto, resolve } from '$lib/router/index.svelte.js'
7
+ import { deleteRecord, createRecord } from '$lib/api/records.js'
8
+ import DeleteDialog from '$lib/components/DeleteDialog.svelte'
9
+ import { toast } from 'svelte-sonner'
10
+
11
+ let {
12
+ collectionKey,
13
+ record,
14
+ mode,
15
+ }: {
16
+ collectionKey: string
17
+ record: Record<string, any> | null
18
+ mode: 'create' | 'edit'
19
+ } = $props()
20
+
21
+ let isOpen = $state(false)
22
+ let deleteOpen = $state(false)
23
+ let isDeleting = $state(false)
24
+ let busy = $state(false)
25
+
26
+ const recordId = $derived((record?.id as string) ?? '')
27
+ const enabled = $derived(mode === 'edit' && recordId !== '')
28
+
29
+ // quoin-managed fields that must not be re-sent when duplicating.
30
+ const systemFields = new Set(['id', 'createdAt', 'updatedAt', 'publishAt', '_status', '_version'])
31
+
32
+ function toggle(e: MouseEvent) {
33
+ e.stopPropagation()
34
+ isOpen = !isOpen
35
+ }
36
+ function close() {
37
+ isOpen = false
38
+ }
39
+
40
+ async function copyId() {
41
+ close()
42
+ try {
43
+ await navigator.clipboard.writeText(recordId)
44
+ toast.success('Record ID copied')
45
+ } catch {
46
+ toast.error('Could not copy ID')
47
+ }
48
+ }
49
+
50
+ async function duplicate() {
51
+ close()
52
+ if (!record || busy) return
53
+ busy = true
54
+ try {
55
+ const data: Record<string, any> = {}
56
+ for (const [k, v] of Object.entries(record)) {
57
+ if (systemFields.has(k)) continue
58
+ // Coerce expanded relationship values back to id(s) for the write API.
59
+ if (Array.isArray(v)) {
60
+ data[k] = v.map((x) => (x && typeof x === 'object' && 'id' in x ? (x as any).id : x))
61
+ } else if (v && typeof v === 'object' && 'id' in v) {
62
+ data[k] = (v as any).id
63
+ } else {
64
+ data[k] = v
65
+ }
66
+ }
67
+ // Avoid unique collisions / signal the copy.
68
+ if (typeof data.slug === 'string' && data.slug) data.slug = `${data.slug}-copy`
69
+ if (typeof data.title === 'string' && data.title) data.title = `${data.title} (Copy)`
70
+ else if (typeof data.name === 'string' && data.name) data.name = `${data.name} (Copy)`
71
+
72
+ const res = await createRecord(collectionKey, data)
73
+ if (!res.ok) {
74
+ toast.error(`Duplicate failed: ${res.error}`)
75
+ return
76
+ }
77
+ toast.success('Duplicated')
78
+ goto(resolve(`/${collectionKey}/${res.data.id}`))
79
+ } finally {
80
+ busy = false
81
+ }
82
+ }
83
+
84
+ function askDelete() {
85
+ close()
86
+ deleteOpen = true
87
+ }
88
+
89
+ async function confirmDelete() {
90
+ isDeleting = true
91
+ const res = await deleteRecord(collectionKey, recordId)
92
+ isDeleting = false
93
+ deleteOpen = false
94
+ if (!res.ok) {
95
+ toast.error(`Delete failed: ${res.error}`)
96
+ return
97
+ }
98
+ toast.success('Record deleted')
99
+ goto(resolve(`/${collectionKey}`))
100
+ }
101
+ </script>
102
+
103
+ <svelte:window onclick={() => { if (isOpen) close() }} />
104
+
105
+ <div class="relative">
106
+ <button
107
+ type="button"
108
+ onclick={toggle}
109
+ disabled={!enabled || busy}
110
+ class="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-stone-500 shadow-sm transition-colors hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
111
+ title="More actions"
112
+ aria-label="More actions"
113
+ aria-haspopup="menu"
114
+ aria-expanded={isOpen}
115
+ >
116
+ <MoreVertical class="h-4 w-4" />
117
+ </button>
118
+
119
+ {#if isOpen}
120
+ <div
121
+ class="absolute right-0 z-20 mt-1 w-44 overflow-hidden rounded-lg border border-border bg-card py-1 shadow-lg"
122
+ role="menu"
123
+ tabindex="-1"
124
+ onclick={(e) => e.stopPropagation()}
125
+ onkeydown={(e) => { if (e.key === 'Escape') close() }}
126
+ >
127
+ <button
128
+ type="button"
129
+ role="menuitem"
130
+ onclick={duplicate}
131
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-foreground hover:bg-secondary"
132
+ >
133
+ <CopyPlus class="h-4 w-4" /> Duplicate
134
+ </button>
135
+ <button
136
+ type="button"
137
+ role="menuitem"
138
+ onclick={copyId}
139
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-foreground hover:bg-secondary"
140
+ >
141
+ <Copy class="h-4 w-4" /> Copy ID
142
+ </button>
143
+ <div class="my-1 h-px bg-border"></div>
144
+ <button
145
+ type="button"
146
+ role="menuitem"
147
+ onclick={askDelete}
148
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
149
+ >
150
+ <Trash2 class="h-4 w-4" /> Delete
151
+ </button>
152
+ </div>
153
+ {/if}
154
+ </div>
155
+
156
+ <DeleteDialog
157
+ bind:open={deleteOpen}
158
+ title="Delete Record"
159
+ description="Are you sure you want to delete this record? This action cannot be undone."
160
+ {isDeleting}
161
+ onConfirm={confirmDelete}
162
+ />
@@ -77,7 +77,7 @@ let {
77
77
  </div>
78
78
 
79
79
  <!-- Row 3: meta + actions -->
80
- <DocMetaStrip {record} mode={docMode} {status} {saveLabel} {isDirty} {isSubmitting} {hasDrafts} {hasSchedulePublish} onSave={onSave} onSaveAs={onSaveAs} {onUnpublish} {onSchedule} {metaExtra} />
80
+ <DocMetaStrip {record} collectionKey={collection.key} mode={docMode} {status} {saveLabel} {isDirty} {isSubmitting} {hasDrafts} {hasSchedulePublish} onSave={onSave} onSaveAs={onSaveAs} {onUnpublish} {onSchedule} {metaExtra} />
81
81
 
82
82
  <!-- Row 4: tabs -->
83
83
  {#if headerMode === 'edit'}
@@ -1,12 +1,14 @@
1
1
  <script lang="ts">
2
2
  import PublishButton from './PublishButton.svelte'
3
- import { ExternalLink, MoreVertical } from 'lucide-svelte'
3
+ import DocActionsMenu from './DocActionsMenu.svelte'
4
+ import { ExternalLink } from 'lucide-svelte'
4
5
  import { formatDate } from '$lib/utils/format.js'
5
6
 
6
7
  import type { Snippet } from 'svelte'
7
8
 
8
9
  let {
9
10
  record,
11
+ collectionKey,
10
12
  mode,
11
13
  status,
12
14
  saveLabel,
@@ -21,6 +23,7 @@ let {
21
23
  metaExtra,
22
24
  }: {
23
25
  record: Record<string, any> | null
26
+ collectionKey: string
24
27
  mode: 'create' | 'edit'
25
28
  status: string
26
29
  saveLabel: string
@@ -91,13 +94,6 @@ let statusLabel = $derived(status ? status.charAt(0).toUpperCase() + status.slic
91
94
  <ExternalLink class="h-4 w-4" />
92
95
  </button>
93
96
  <PublishButton label={saveLabel} currentStatus={status} {isSubmitting} {isDirty} {hasSchedulePublish} onSave={onSave} onSaveAs={onSaveAs} {onSchedule} />
94
- <button
95
- type="button"
96
- disabled
97
- class="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-stone-500 opacity-50 shadow-sm"
98
- title="More actions (coming soon)"
99
- >
100
- <MoreVertical class="h-4 w-4" />
101
- </button>
97
+ <DocActionsMenu {collectionKey} {record} {mode} />
102
98
  </div>
103
99
  </div>
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte'
3
+ import type { FieldSchema } from '$lib/types/schema.js'
4
+ import { getQuoinContext, resolveField, loadComponent } from '$lib/context.svelte.js'
5
+ import { resolveFieldComponent } from './registry.js'
6
+
7
+ let {
8
+ field,
9
+ value = $bindable(),
10
+ error = undefined,
11
+ formData = {},
12
+ }: {
13
+ field: FieldSchema
14
+ value?: unknown
15
+ error?: string
16
+ formData?: Record<string, unknown>
17
+ } = $props()
18
+
19
+ const ctx = getQuoinContext()
20
+ const overridePath = $derived(resolveField(field.type, ctx.config))
21
+ const Builtin = $derived(resolveFieldComponent(field))
22
+ let Override = $state<Component<any> | null>(null)
23
+ let overrideFailed = $state(false)
24
+
25
+ $effect(() => {
26
+ if (overridePath) {
27
+ overrideFailed = false
28
+ loadComponent(overridePath, ctx.importMap)
29
+ .then((c) => (Override = c))
30
+ .catch(() => {
31
+ Override = null
32
+ overrideFailed = true
33
+ })
34
+ }
35
+ })
36
+ </script>
37
+
38
+ {#if overridePath && Override}
39
+ {@const C = Override}
40
+ <C {field} bind:value {error} {formData} />
41
+ {:else if overridePath && !overrideFailed}
42
+ <div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
43
+ {:else if Builtin}
44
+ {@const C = Builtin}
45
+ <C {field} bind:value {error} {formData} />
46
+ {:else}
47
+ <p class="text-sm text-muted-foreground">Unknown field type: {field.type}</p>
48
+ {/if}
@@ -60,7 +60,14 @@ function setData(next: Record<string, unknown>): void {
60
60
  }
61
61
  </script>
62
62
 
63
- <div class="my-3 rounded-md border bg-card p-3">
63
+ <!--
64
+ whitespace-normal: the editor's contenteditable root sets `white-space:
65
+ pre-wrap`, which decorator blocks inherit. Without this reset, the newlines
66
+ and spaces between block-level elements in each block's markup render as
67
+ preserved whitespace (tall empty line boxes / large vertical gaps). Resetting
68
+ to normal here lets that whitespace collapse as it would outside the editor.
69
+ -->
70
+ <div class="my-3 whitespace-normal rounded-md border bg-card p-3">
64
71
  {#if !def}
65
72
  <p class="text-sm text-destructive">Unknown block: {blockType}</p>
66
73
  {:else}
@@ -86,6 +86,8 @@ export interface AdminConfig {
86
86
  group?: string
87
87
  sidebarFields?: string[]
88
88
  description?: string
89
+ /** When true, the collection is omitted from the sidebar nav (still reachable by URL / API). */
90
+ hidden?: boolean
89
91
  }
90
92
 
91
93
  export interface CollectionSchema {
@@ -103,6 +105,7 @@ export interface CollectionSchema {
103
105
  export interface GlobalSchema {
104
106
  key: string
105
107
  label: string
108
+ description?: string
106
109
  fields: FieldSchema[]
107
110
  }
108
111
 
@@ -32,7 +32,7 @@ export function useDirtyState<T extends Record<string, unknown>>(
32
32
  return () => window.removeEventListener('beforeunload', handler)
33
33
  })
34
34
 
35
- beforeNavigate(({ cancel }) => {
35
+ beforeNavigate((cancel) => {
36
36
  if (bypass) return
37
37
  if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) {
38
38
  cancel()
@@ -25,7 +25,7 @@ onMount(async () => {
25
25
  const result = await createRecord(collectionKey, { _status: 'draft' }, { draft: true })
26
26
  autoCreating = false
27
27
  if (result.ok && result.data.id) {
28
- await goto(resolve(`/${collectionKey}/${result.data.id}`), { replaceState: true })
28
+ await goto(resolve(`/${collectionKey}/${result.data.id}`), { replace: true })
29
29
  }
30
30
  }
31
31
  })