@quoin-cms/admin 0.2.0 → 0.3.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.
Files changed (32) hide show
  1. package/package.json +4 -1
  2. package/src/AdminRoot.svelte +5 -1
  3. package/src/lib/api/auth.ts +3 -6
  4. package/src/lib/api/files.ts +3 -2
  5. package/src/lib/components/AdminSidebar.svelte +23 -2
  6. package/src/lib/components/DocumentEditLayout.svelte +17 -0
  7. package/src/lib/components/DynamicForm.svelte +3 -3
  8. package/src/lib/components/MediaLibrary.svelte +55 -7
  9. package/src/lib/components/UploadCreateView.svelte +173 -0
  10. package/src/lib/components/doc/ApiView.svelte +95 -103
  11. package/src/lib/components/doc/RenderJson.svelte +93 -0
  12. package/src/lib/components/fields/RichTextField.svelte +5 -0
  13. package/src/lib/components/fields/UploadField.svelte +26 -34
  14. package/src/lib/components/fields/UploadGalleryField.svelte +28 -37
  15. package/src/lib/components/lexical/BlockField.svelte +41 -0
  16. package/src/lib/components/lexical/BlockHost.svelte +85 -0
  17. package/src/lib/components/lexical/BlockNode.ts +102 -0
  18. package/src/lib/components/lexical/block-defaults.ts +40 -0
  19. package/src/lib/components/lexical/lexical-helpers.ts +3 -0
  20. package/src/lib/components/lexical/nodes.ts +2 -0
  21. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +27 -2
  22. package/src/lib/context.svelte.ts +1 -0
  23. package/src/lib/types/schema.ts +5 -0
  24. package/src/views/CollectionNewView.svelte +3 -0
  25. package/src/views/LoginView.svelte +47 -23
  26. package/biome.json +0 -62
  27. package/dist/assets/index-BaOy5Of3.js +0 -32
  28. package/dist/assets/index-DINUk481.css +0 -1
  29. package/dist/index.html +0 -20
  30. package/index.html +0 -19
  31. package/tsconfig.json +0 -25
  32. package/vite.config.ts +0 -80
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@quoin-cms/admin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
+ "files": [
7
+ "src"
8
+ ],
6
9
  "description": "Quoin CMS admin — Svelte 5 SPA",
7
10
  "license": "AGPL-3.0-or-later",
8
11
  "devDependencies": {
@@ -33,7 +33,7 @@
33
33
  import GlobalEditView from './views/GlobalEditView.svelte'
34
34
  import CustomPageView from './views/CustomPageView.svelte'
35
35
  import NotFoundView from './views/NotFoundView.svelte'
36
- import { seedBrandingFromConfig } from './lib/stores/branding.svelte.js'
36
+ import { seedBrandingFromConfig, branding } from './lib/stores/branding.svelte.js'
37
37
 
38
38
  let {
39
39
  config,
@@ -48,6 +48,10 @@
48
48
  // Later, site-settings loadBranding() can override with operator-edited values.
49
49
  seedBrandingFromConfig()
50
50
 
51
+ $effect(() => {
52
+ document.title = branding.siteName
53
+ })
54
+
51
55
  // Routes are matched in order — most specific patterns first.
52
56
  const routes: RoutePattern[] = [
53
57
  { pattern: '/login' },
@@ -16,11 +16,8 @@ export function getMe(): Promise<ApiResult<AuthUser>> {
16
16
 
17
17
  export function register(
18
18
  username: string,
19
- password: string
19
+ password: string,
20
+ name: string
20
21
  ): Promise<ApiResult<{ message: string }>> {
21
- return post<{ message: string }>('/auth/register', { username, password })
22
- }
23
-
24
- export function getSetupStatus(): Promise<ApiResult<{ needsSetup: boolean }>> {
25
- return get<{ needsSetup: boolean }>('/auth/setup-status')
22
+ return post<{ message: string }>('/auth/register', { username, password, name })
26
23
  }
@@ -33,7 +33,7 @@ export interface UploadRecord {
33
33
 
34
34
  export interface UploadToCollectionOptions {
35
35
  /** Optional user-metadata form fields to include alongside `file`. */
36
- extra?: Record<string, string>
36
+ extra?: Record<string, unknown>
37
37
  }
38
38
 
39
39
  /**
@@ -50,7 +50,8 @@ export function uploadToCollection(
50
50
  const fd = new FormData()
51
51
  fd.append('file', file)
52
52
  for (const [k, v] of Object.entries(opts.extra ?? {})) {
53
- fd.append(k, v)
53
+ if (v === undefined || v === null || v === '') continue
54
+ fd.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
54
55
  }
55
56
  return upload<UploadRecord>(`/upload/${encodeURIComponent(collection)}`, fd)
56
57
  }
@@ -12,7 +12,7 @@ import {
12
12
  FileText, LogOut, Settings, PanelLeftClose, PanelLeftOpen, BookOpen,
13
13
  PenLine, Users, FolderTree, Tag, BookCopy, Trophy, Megaphone,
14
14
  Mail, StickyNote, Menu, Image, FolderOpen, Tags,
15
- MessageSquare, BarChart3, MousePointerClick, Puzzle, type Icon
15
+ MessageSquare, BarChart3, MousePointerClick, Puzzle, LayoutDashboard, User, type Icon
16
16
  } from 'lucide-svelte'
17
17
  import type { Component } from 'svelte'
18
18
 
@@ -134,6 +134,23 @@ async function handleLogout() {
134
134
 
135
135
  <!-- Navigation -->
136
136
  <nav class="flex-1 overflow-y-auto px-3 py-4 scrollbar-hide">
137
+ <div class="mb-5">
138
+ <ul class="space-y-0.5">
139
+ <li>
140
+ <a
141
+ href={resolve('/')}
142
+ class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
143
+ {currentPath === resolve('/')
144
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
145
+ : 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
146
+ >
147
+ <LayoutDashboard class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {currentPath === resolve('/') ? 'opacity-100' : ''}" />
148
+ Dashboard
149
+ </a>
150
+ </li>
151
+ </ul>
152
+ </div>
153
+
137
154
  {#each groupNames as group}
138
155
  <div class="mb-5">
139
156
  <p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
@@ -216,7 +233,11 @@ async function handleLogout() {
216
233
  <div class="flex items-center justify-between">
217
234
  <div class="flex items-center gap-2.5">
218
235
  <div class="flex h-7 w-7 items-center justify-center rounded-full bg-sidebar-accent text-[11px] font-semibold text-sidebar-accent-foreground">
219
- {user?.username?.charAt(0)?.toUpperCase() || '?'}
236
+ {#if user?.username}
237
+ {user.username.charAt(0).toUpperCase()}
238
+ {:else}
239
+ <User class="h-3.5 w-3.5" />
240
+ {/if}
220
241
  </div>
221
242
  <span class="text-xs text-sidebar-foreground">{user?.username}</span>
222
243
  </div>
@@ -6,6 +6,7 @@ import VersionHistory from './doc/VersionHistory.svelte'
6
6
  import ScheduleModal from './doc/ScheduleModal.svelte'
7
7
  import DynamicForm from './DynamicForm.svelte'
8
8
  import { resolveFieldComponent } from './fields/index.js'
9
+ import { formatFileSize } from '$lib/utils/format.js'
9
10
  import { useDirtyState } from '$lib/utils/dirty.svelte.js'
10
11
  import { unpublishRecord } from '$lib/api/records.js'
11
12
  import { toast } from 'svelte-sonner'
@@ -231,6 +232,22 @@ function handleSchedule(isoDate: string) {
231
232
  {:else}
232
233
  <div class="grid gap-9 px-7 py-8 {sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id) ? 'lg:grid-cols-[1fr_320px]' : ''}">
233
234
  <div class="min-w-0">
235
+ {#if collection.upload && record?.url}
236
+ <div class="mb-6 flex items-start gap-4 rounded-lg border bg-muted/30 p-4">
237
+ {#if String(record.mimeType ?? '').startsWith('image/')}
238
+ <img src={record.url} alt={record.filename ?? ''} class="h-24 w-24 shrink-0 rounded object-cover" />
239
+ {:else}
240
+ <div class="flex h-24 w-24 shrink-0 items-center justify-center rounded bg-muted text-2xl">📄</div>
241
+ {/if}
242
+ <div class="min-w-0 space-y-1">
243
+ <p class="truncate font-medium">{record.filename}</p>
244
+ <p class="text-sm text-muted-foreground">
245
+ {formatFileSize(record.size ?? 0)}{#if record.width}{' · '}{record.width}×{record.height}{/if}{' · '}{record.mimeType}
246
+ </p>
247
+ <a href={record.url} target="_blank" rel="noopener" class="inline-block text-sm text-primary hover:underline">Open file</a>
248
+ </div>
249
+ </div>
250
+ {/if}
234
251
  <DynamicForm
235
252
  fields={collection.fields}
236
253
  initialData={record ?? {}}
@@ -22,7 +22,7 @@ let {
22
22
 
23
23
  let sidebarFieldSet = $derived(new Set(sidebarFields))
24
24
  let mainFields = $derived(
25
- fields.filter((f) => !f.hidden && !sidebarFieldSet.has(f.name))
25
+ fields.filter((f) => !f.hidden && !f.adminHidden && !sidebarFieldSet.has(f.name))
26
26
  )
27
27
 
28
28
  // formData is owned by the parent (DocumentEditLayout) and bound in.
@@ -52,7 +52,7 @@ let mergedErrors = $derived({ ...errors, ...localErrors })
52
52
  {#each (tabsField.tabs[activeTab].fields ?? []) as cf (cf.name)}
53
53
  {#if cf.type === 'row'}
54
54
  <div class="grid gap-4" style="grid-template-columns: repeat({(cf.fields ?? []).length}, minmax(0, 1fr));">
55
- {#each (cf.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
55
+ {#each (cf.fields ?? []).filter((x: FieldSchema) => !x.hidden && !x.adminHidden) as rf (rf.name)}
56
56
  {@render fieldRenderer(rf)}
57
57
  {/each}
58
58
  </div>
@@ -63,7 +63,7 @@ let mergedErrors = $derived({ ...errors, ...localErrors })
63
63
  {/if}
64
64
  {:else if f.type === 'row'}
65
65
  <div class="grid gap-4" style="grid-template-columns: repeat({(f.fields ?? []).length}, minmax(0, 1fr));">
66
- {#each (f.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
66
+ {#each (f.fields ?? []).filter((x: FieldSchema) => !x.hidden && !x.adminHidden) as rf (rf.name)}
67
67
  {@render fieldRenderer(rf)}
68
68
  {/each}
69
69
  </div>
@@ -3,9 +3,10 @@ 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 { X, Upload, Search, FolderOpen, FileText } from 'lucide-svelte';
6
+ import { X, Upload, Search, FolderOpen, FileText, Check } from 'lucide-svelte';
7
7
 
8
8
  interface MediaItem {
9
+ id: string;
9
10
  url: string;
10
11
  alt: string;
11
12
  filename: string;
@@ -16,13 +17,23 @@ interface MediaItem {
16
17
  let {
17
18
  open = $bindable(false),
18
19
  accept = ['image/*'],
20
+ multiple = false,
21
+ collection = 'media',
19
22
  onSelect,
20
23
  }: {
21
24
  open: boolean;
22
25
  accept?: string[];
26
+ /** When true, items toggle into a selection the user confirms via "Add". */
27
+ multiple?: boolean;
28
+ /** Upload collection slug to browse (and its `<collection>_folders`). */
29
+ collection?: string;
23
30
  onSelect: (media: MediaItem) => void;
24
31
  } = $props();
25
32
 
33
+ // In multi-select mode we accumulate chosen items here (keyed by id) so the
34
+ // selection survives pagination, then flush them on confirm.
35
+ let selected = $state<Map<string, MediaItem>>(new Map());
36
+
26
37
  let items = $state<any[]>([]);
27
38
  let folders = $state<any[]>([]);
28
39
  let search = $state('');
@@ -48,7 +59,7 @@ async function loadMedia() {
48
59
  if (selectedFolder) {
49
60
  params.filter = `folder="${selectedFolder}"`;
50
61
  }
51
- const result = await listRecords('media', params);
62
+ const result = await listRecords(collection, params);
52
63
  if (result.ok) {
53
64
  items = result.data.records;
54
65
  totalDocs = result.data.totalRecords;
@@ -58,21 +69,37 @@ async function loadMedia() {
58
69
  }
59
70
 
60
71
  async function loadFolders() {
61
- const result = await listRecords('media_folders', { perPage: 100, sort: 'name' });
72
+ const result = await listRecords(`${collection}_folders`, { perPage: 100, sort: 'name' });
62
73
  if (result.ok) {
63
74
  folders = result.data.records;
64
75
  }
65
76
  }
66
77
 
67
- function handleSelect(item: any) {
68
- const media: MediaItem = {
78
+ function buildMediaItem(item: any): MediaItem {
79
+ return {
80
+ id: item.id,
69
81
  url: item.file?.url || item.url || '',
70
82
  alt: item.alt || '',
71
83
  filename: item.filename || item.file?.filename || '',
72
84
  mimeType: item.mimeType || item.file?.mimeType || '',
73
85
  size: item.size || item.file?.size || 0,
74
86
  };
75
- onSelect(media);
87
+ }
88
+
89
+ function handleSelect(item: any) {
90
+ if (multiple) {
91
+ const next = new Map(selected);
92
+ if (next.has(item.id)) next.delete(item.id);
93
+ else next.set(item.id, buildMediaItem(item));
94
+ selected = next;
95
+ return;
96
+ }
97
+ onSelect(buildMediaItem(item));
98
+ open = false;
99
+ }
100
+
101
+ function confirmSelection() {
102
+ for (const media of selected.values()) onSelect(media);
76
103
  open = false;
77
104
  }
78
105
 
@@ -144,6 +171,7 @@ $effect(() => {
144
171
  page = 1;
145
172
  search = '';
146
173
  selectedFolder = null;
174
+ selected = new Map();
147
175
  loadMedia();
148
176
  loadFolders();
149
177
  }
@@ -246,9 +274,14 @@ $effect(() => {
246
274
  {#each items as item}
247
275
  <button
248
276
  type="button"
249
- class="group flex flex-col overflow-hidden rounded-md border hover:border-primary hover:shadow-sm transition-all"
277
+ class="group relative flex flex-col overflow-hidden rounded-md border hover:shadow-sm transition-all {multiple && selected.has(item.id) ? 'border-primary ring-2 ring-primary' : 'hover:border-primary'}"
250
278
  onclick={() => handleSelect(item)}
251
279
  >
280
+ {#if multiple && selected.has(item.id)}
281
+ <div class="absolute right-1.5 top-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
282
+ <Check class="h-3.5 w-3.5" />
283
+ </div>
284
+ {/if}
252
285
  <div class="aspect-square w-full overflow-hidden bg-muted">
253
286
  {#if isImage(item.mimeType || item.file?.mimeType || '')}
254
287
  <img
@@ -273,6 +306,21 @@ $effect(() => {
273
306
  </div>
274
307
  </div>
275
308
 
309
+ <!-- Multi-select confirm bar -->
310
+ {#if multiple}
311
+ <div class="flex items-center justify-between border-t px-4 py-2">
312
+ <p class="text-xs text-muted-foreground">{selected.size} selected</p>
313
+ <button
314
+ type="button"
315
+ disabled={selected.size === 0}
316
+ onclick={confirmSelection}
317
+ class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-3 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
318
+ >
319
+ Add {selected.size} {selected.size === 1 ? 'item' : 'items'}
320
+ </button>
321
+ </div>
322
+ {/if}
323
+
276
324
  <!-- Pagination -->
277
325
  {#if totalPages > 1}
278
326
  <div class="flex items-center justify-between border-t px-4 py-2">
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import { goto, resolve } from '$lib/router/index.svelte.js'
3
+ import { uploadToCollection } from '$lib/api/files.js'
4
+ import DynamicForm from './DynamicForm.svelte'
5
+ import type { CollectionSchema, FieldSchema } from '$lib/types/schema.js'
6
+ import { toast } from 'svelte-sonner'
7
+ import { Upload, X } from 'lucide-svelte'
8
+
9
+ let {
10
+ collection,
11
+ }: {
12
+ collection: CollectionSchema
13
+ } = $props()
14
+
15
+ let isUploading = $state(false)
16
+ let isDragging = $state(false)
17
+ let selectedFile = $state<File | null>(null)
18
+ let fileInput: HTMLInputElement
19
+
20
+ let accept = $derived(collection.upload?.accept?.join(',') ?? '')
21
+
22
+ function flatten(flds: FieldSchema[]): FieldSchema[] {
23
+ const out: FieldSchema[] = []
24
+ for (const f of flds) {
25
+ if (f.type === 'tabs') {
26
+ for (const t of f.tabs ?? []) out.push(...flatten(t.fields ?? []))
27
+ continue
28
+ }
29
+ if (f.type === 'row') {
30
+ out.push(...flatten(f.fields ?? []))
31
+ continue
32
+ }
33
+ out.push(f)
34
+ }
35
+ return out
36
+ }
37
+
38
+ function buildInitial(flds: FieldSchema[]): Record<string, any> {
39
+ const data: Record<string, any> = {}
40
+ for (const f of flatten(flds)) {
41
+ if ((f as any).defaultValue !== undefined) {
42
+ data[f.name] = (f as any).defaultValue
43
+ } else if (f.type === 'checkbox') {
44
+ data[f.name] = false
45
+ } else if (f.type === 'tags') {
46
+ data[f.name] = []
47
+ } else if (f.type === 'number' || f.type === 'file' || f.type === 'relationship' || f.type === 'richtext') {
48
+ data[f.name] = null
49
+ } else {
50
+ data[f.name] = ''
51
+ }
52
+ }
53
+ return data
54
+ }
55
+
56
+ // svelte-ignore state_referenced_locally
57
+ let formData = $state<Record<string, any>>(buildInitial(collection.fields))
58
+ let userMetadataFields = $derived(flatten(collection.fields).filter((f) => !f.hidden && !f.adminHidden))
59
+ let hasUserMetadata = $derived(userMetadataFields.length > 0)
60
+
61
+ function buildExtra(): Record<string, unknown> {
62
+ const out: Record<string, unknown> = {}
63
+ for (const f of userMetadataFields) {
64
+ out[f.name] = formData[f.name]
65
+ }
66
+ return out
67
+ }
68
+
69
+ function handleFiles(files: FileList | null) {
70
+ if (!files?.length || isUploading) return
71
+ selectedFile = files[0]
72
+ if (fileInput) fileInput.value = ''
73
+ }
74
+
75
+ function clearFile() {
76
+ selectedFile = null
77
+ if (fileInput) fileInput.value = ''
78
+ }
79
+
80
+ async function submitUpload() {
81
+ if (!selectedFile || isUploading) return
82
+ isUploading = true
83
+ const result = await uploadToCollection(collection.key, selectedFile, { extra: buildExtra() })
84
+ isUploading = false
85
+ if (result.ok && result.data.id) {
86
+ toast.success(`Uploaded ${selectedFile.name}`)
87
+ await goto(resolve(`/${collection.key}/${result.data.id}`))
88
+ } else {
89
+ toast.error(!result.ok ? result.error : 'Upload failed')
90
+ }
91
+ }
92
+
93
+ function onDrop(e: DragEvent) {
94
+ e.preventDefault()
95
+ isDragging = false
96
+ handleFiles(e.dataTransfer?.files ?? null)
97
+ }
98
+ </script>
99
+
100
+ <div class="px-7 py-8">
101
+ <h1 class="mb-6 text-2xl font-semibold">New {collection.label}</h1>
102
+
103
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
104
+ <div
105
+ class="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-12 text-center transition-colors {isDragging ? 'border-primary bg-accent/40' : 'border-border'}"
106
+ ondragover={(e) => { e.preventDefault(); isDragging = true }}
107
+ ondragleave={() => (isDragging = false)}
108
+ ondrop={onDrop}
109
+ >
110
+ <Upload class="h-8 w-8 text-muted-foreground" />
111
+ <div class="flex items-center gap-2">
112
+ <button
113
+ type="button"
114
+ onclick={() => fileInput.click()}
115
+ disabled={isUploading}
116
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50"
117
+ >
118
+ {selectedFile ? 'Change file' : 'Select a file'}
119
+ </button>
120
+ <span class="text-sm text-muted-foreground">or drag and drop a file</span>
121
+ </div>
122
+ {#if selectedFile}
123
+ <div class="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
124
+ <span class="truncate">{selectedFile.name}</span>
125
+ <button
126
+ type="button"
127
+ onclick={clearFile}
128
+ disabled={isUploading}
129
+ class="text-muted-foreground hover:text-foreground disabled:opacity-50"
130
+ aria-label="Remove selected file"
131
+ >
132
+ <X class="h-4 w-4" />
133
+ </button>
134
+ </div>
135
+ {/if}
136
+ {#if accept}
137
+ <p class="text-xs text-muted-foreground">Accepted: {accept}</p>
138
+ {/if}
139
+ </div>
140
+
141
+ {#if hasUserMetadata}
142
+ <div class="mt-8 max-w-3xl">
143
+ <DynamicForm fields={collection.fields} bind:formData />
144
+ </div>
145
+ {/if}
146
+
147
+ <div class="mt-8 flex items-center gap-3">
148
+ <button
149
+ type="button"
150
+ onclick={submitUpload}
151
+ disabled={!selectedFile || isUploading}
152
+ class="inline-flex h-10 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
153
+ >
154
+ {isUploading ? 'Uploading…' : 'Upload'}
155
+ </button>
156
+ <button
157
+ type="button"
158
+ onclick={() => goto(resolve(`/${collection.key}`))}
159
+ disabled={isUploading}
160
+ class="inline-flex h-10 items-center rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50"
161
+ >
162
+ Cancel
163
+ </button>
164
+ </div>
165
+
166
+ <input
167
+ bind:this={fileInput}
168
+ type="file"
169
+ class="hidden"
170
+ {accept}
171
+ onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
172
+ />
173
+ </div>