@quoin-cms/admin 0.3.1 → 0.6.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 +1 -0
- package/package.json +1 -1
- package/src/AdminRoot.svelte +4 -0
- package/src/lib/Slot.svelte +2 -2
- package/src/lib/api/appearance.ts +16 -0
- package/src/lib/components/AdminSidebar.svelte +28 -4
- package/src/lib/components/DocumentEditLayout.svelte +7 -5
- package/src/lib/components/DynamicForm.svelte +7 -14
- package/src/lib/components/MediaLibrary.svelte +13 -6
- package/src/lib/components/doc/DocActionsMenu.svelte +162 -0
- package/src/lib/components/doc/DocHeader.svelte +1 -1
- package/src/lib/components/doc/DocMetaStrip.svelte +5 -9
- package/src/lib/components/fields/ArrayFieldEditor.svelte +15 -9
- package/src/lib/components/fields/BlocksFieldEditor.svelte +15 -9
- package/src/lib/components/fields/FieldWidget.svelte +48 -0
- package/src/lib/components/lexical/BlockHost.svelte +8 -1
- package/src/lib/stores/theme.svelte.ts +63 -0
- package/src/lib/types/schema.ts +3 -0
- package/src/lib/utils/dirty.svelte.ts +1 -1
- package/src/views/AppearanceView.svelte +201 -0
- package/src/views/CollectionNewView.svelte +1 -1
package/index.html
CHANGED
package/package.json
CHANGED
package/src/AdminRoot.svelte
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import VersionsListView from './views/VersionsListView.svelte'
|
|
32
32
|
import VersionDetailView from './views/VersionDetailView.svelte'
|
|
33
33
|
import GlobalEditView from './views/GlobalEditView.svelte'
|
|
34
|
+
import AppearanceView from './views/AppearanceView.svelte'
|
|
34
35
|
import CustomPageView from './views/CustomPageView.svelte'
|
|
35
36
|
import NotFoundView from './views/NotFoundView.svelte'
|
|
36
37
|
import { seedBrandingFromConfig, branding } from './lib/stores/branding.svelte.js'
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
{ pattern: '/:collection/:id/versions' },
|
|
62
63
|
{ pattern: '/:collection/new' },
|
|
63
64
|
{ pattern: '/:collection/:id' },
|
|
65
|
+
{ pattern: '/appearance' },
|
|
64
66
|
{ pattern: '/:collection' },
|
|
65
67
|
{ pattern: '/' },
|
|
66
68
|
]
|
|
@@ -89,6 +91,8 @@
|
|
|
89
91
|
<CollectionNewView />
|
|
90
92
|
{:else if /^\/[^/]+\/[^/]+$/.test(path)}
|
|
91
93
|
<CollectionEditView />
|
|
94
|
+
{:else if path === '/appearance'}
|
|
95
|
+
<AppearanceView />
|
|
92
96
|
{:else if /^\/[^/]+$/.test(path)}
|
|
93
97
|
<CollectionListView />
|
|
94
98
|
{:else}
|
package/src/lib/Slot.svelte
CHANGED
|
@@ -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}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { get, put } from './client.js'
|
|
2
|
+
import type { ApiResult } from './client.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Appearance theme map: CSS-token name (without leading `--`) → value.
|
|
6
|
+
* e.g. `{ "color-primary": "#0F766E" }`. Super-admin only on the backend.
|
|
7
|
+
*/
|
|
8
|
+
export function getAppearance(): Promise<ApiResult<Record<string, string>>> {
|
|
9
|
+
return get<Record<string, string>>('/appearance')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function putAppearance(
|
|
13
|
+
theme: Record<string, string>
|
|
14
|
+
): Promise<ApiResult<Record<string, string>>> {
|
|
15
|
+
return put<Record<string, string>>('/appearance', theme)
|
|
16
|
+
}
|
|
@@ -12,9 +12,9 @@ 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, LayoutDashboard, User, type Icon
|
|
15
|
+
MessageSquare, BarChart3, MousePointerClick, Puzzle, LayoutDashboard, User, Palette, 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)
|
|
@@ -226,6 +227,29 @@ async function handleLogout() {
|
|
|
226
227
|
</ul>
|
|
227
228
|
</div>
|
|
228
229
|
{/if}
|
|
230
|
+
|
|
231
|
+
<!-- Appearance (super-admin only) -->
|
|
232
|
+
{#if user?.role === 'super-admin'}
|
|
233
|
+
<div class="mb-5">
|
|
234
|
+
<p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
|
|
235
|
+
System
|
|
236
|
+
</p>
|
|
237
|
+
<ul class="space-y-0.5">
|
|
238
|
+
<li>
|
|
239
|
+
<a
|
|
240
|
+
href={resolve('/appearance')}
|
|
241
|
+
class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
|
|
242
|
+
{isActive(resolve('/appearance'))
|
|
243
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
|
|
244
|
+
: 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
|
|
245
|
+
>
|
|
246
|
+
<Palette class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {isActive(resolve('/appearance')) ? 'opacity-100' : ''}" />
|
|
247
|
+
Appearance
|
|
248
|
+
</a>
|
|
249
|
+
</li>
|
|
250
|
+
</ul>
|
|
251
|
+
</div>
|
|
252
|
+
{/if}
|
|
229
253
|
</nav>
|
|
230
254
|
|
|
231
255
|
<!-- Footer -->
|
|
@@ -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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
<
|
|
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>
|
|
@@ -3,40 +3,46 @@
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
field: { name: string; label?: string; labels?: { singular?: string; plural?: string } };
|
|
6
|
-
value
|
|
7
|
-
|
|
6
|
+
// Two-way bound by FieldWidget (`bind:value`); writes go straight back.
|
|
7
|
+
value?: any[];
|
|
8
8
|
}
|
|
9
|
-
let { field, value
|
|
9
|
+
let { field, value = $bindable([]) }: Props = $props();
|
|
10
10
|
|
|
11
11
|
const rows = $derived(Array.isArray(value) ? value : []);
|
|
12
12
|
const singular = $derived(field.labels?.singular ?? 'Item');
|
|
13
13
|
|
|
14
14
|
function genId(): string {
|
|
15
|
-
|
|
15
|
+
// randomUUID is only defined in secure contexts; fall back so array
|
|
16
|
+
// rows can still be added over plain HTTP. Server Sanitize upgrades
|
|
17
|
+
// a non-UUID id to a v7 on save.
|
|
18
|
+
return (
|
|
19
|
+
crypto.randomUUID?.() ??
|
|
20
|
+
`tmp-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`
|
|
21
|
+
);
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
function add() {
|
|
19
|
-
|
|
25
|
+
value = [...rows, { id: genId() }];
|
|
20
26
|
}
|
|
21
27
|
function update(i: number, next: any) {
|
|
22
28
|
const out = [...rows];
|
|
23
29
|
out[i] = next;
|
|
24
|
-
|
|
30
|
+
value = out;
|
|
25
31
|
}
|
|
26
32
|
function remove(i: number) {
|
|
27
|
-
|
|
33
|
+
value = rows.filter((_, j) => j !== i);
|
|
28
34
|
}
|
|
29
35
|
function moveUp(i: number) {
|
|
30
36
|
if (i === 0) return;
|
|
31
37
|
const out = [...rows];
|
|
32
38
|
[out[i - 1], out[i]] = [out[i], out[i - 1]];
|
|
33
|
-
|
|
39
|
+
value = out;
|
|
34
40
|
}
|
|
35
41
|
function moveDown(i: number) {
|
|
36
42
|
if (i === rows.length - 1) return;
|
|
37
43
|
const out = [...rows];
|
|
38
44
|
[out[i], out[i + 1]] = [out[i + 1], out[i]];
|
|
39
|
-
|
|
45
|
+
value = out;
|
|
40
46
|
}
|
|
41
47
|
</script>
|
|
42
48
|
|
|
@@ -13,43 +13,49 @@
|
|
|
13
13
|
blocks: BlockDef[]; // resolved (registry pre-merged)
|
|
14
14
|
allowedBlocks?: string[];
|
|
15
15
|
};
|
|
16
|
-
value
|
|
17
|
-
|
|
16
|
+
// Two-way bound by FieldWidget (`bind:value`); writes go straight back.
|
|
17
|
+
value?: any[];
|
|
18
18
|
}
|
|
19
|
-
let { field, value
|
|
19
|
+
let { field, value = $bindable([]) }: Props = $props();
|
|
20
20
|
|
|
21
21
|
const rows = $derived(Array.isArray(value) ? value : []);
|
|
22
22
|
let pickerSlug = $state(field.blocks[0]?.slug ?? '');
|
|
23
23
|
|
|
24
24
|
function genId(): string {
|
|
25
|
-
|
|
25
|
+
// randomUUID is only defined in secure contexts; fall back so blocks
|
|
26
|
+
// can still be added over plain HTTP. Server Sanitize upgrades a
|
|
27
|
+
// non-UUID id to a v7 on save.
|
|
28
|
+
return (
|
|
29
|
+
crypto.randomUUID?.() ??
|
|
30
|
+
`tmp-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`
|
|
31
|
+
);
|
|
26
32
|
}
|
|
27
33
|
function defaultsFor(slug: string): Record<string, any> {
|
|
28
34
|
return { id: genId(), blockType: slug };
|
|
29
35
|
}
|
|
30
36
|
function add() {
|
|
31
37
|
if (!pickerSlug) return;
|
|
32
|
-
|
|
38
|
+
value = [...rows, defaultsFor(pickerSlug)];
|
|
33
39
|
}
|
|
34
40
|
function update(i: number, next: any) {
|
|
35
41
|
const out = [...rows];
|
|
36
42
|
out[i] = next;
|
|
37
|
-
|
|
43
|
+
value = out;
|
|
38
44
|
}
|
|
39
45
|
function remove(i: number) {
|
|
40
|
-
|
|
46
|
+
value = rows.filter((_, j) => j !== i);
|
|
41
47
|
}
|
|
42
48
|
function moveUp(i: number) {
|
|
43
49
|
if (i === 0) return;
|
|
44
50
|
const out = [...rows];
|
|
45
51
|
[out[i - 1], out[i]] = [out[i], out[i - 1]];
|
|
46
|
-
|
|
52
|
+
value = out;
|
|
47
53
|
}
|
|
48
54
|
function moveDown(i: number) {
|
|
49
55
|
if (i === rows.length - 1) return;
|
|
50
56
|
const out = [...rows];
|
|
51
57
|
[out[i], out[i + 1]] = [out[i + 1], out[i]];
|
|
52
|
-
|
|
58
|
+
value = out;
|
|
53
59
|
}
|
|
54
60
|
</script>
|
|
55
61
|
|
|
@@ -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
|
-
|
|
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}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin UI theme store.
|
|
3
|
+
*
|
|
4
|
+
* The canonical theme is injected server-side into `index.html` at load time
|
|
5
|
+
* (a `<style id="quoin-theme">` block built from the stored override map), so
|
|
6
|
+
* the app paints the correct colors before first paint with no flash and no
|
|
7
|
+
* fetch. This store therefore does NOT load or fetch on view — its job is
|
|
8
|
+
* only LIVE PREVIEW while a super-admin edits in the Appearance page, plus
|
|
9
|
+
* reset of those inline overrides.
|
|
10
|
+
*
|
|
11
|
+
* Theme tokens are CSS custom properties stored WITHOUT the leading `--`
|
|
12
|
+
* (e.g. `color-primary` ↔ `--color-primary`). `app.css` (`@theme`) remains the
|
|
13
|
+
* single source of truth for defaults — no hex is duplicated here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The exposed-token list: the single source of which tokens the UI surfaces.
|
|
18
|
+
* The Appearance page renders one picker per entry. Widening to the full
|
|
19
|
+
* palette later = appending entries here; no backend or store change.
|
|
20
|
+
*/
|
|
21
|
+
export const THEME_TOKENS: { token: string; label: string }[] = [
|
|
22
|
+
{ token: 'color-primary', label: 'Primary' },
|
|
23
|
+
{ token: 'color-accent', label: 'Accent' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Live-preview a theme map by writing each token as an inline override on
|
|
28
|
+
* `:root`. Used while editing, before Save. Empty/invalid values are skipped
|
|
29
|
+
* silently so a blank picker doesn't clobber the injected/default value.
|
|
30
|
+
*/
|
|
31
|
+
export function applyTheme(theme: Record<string, string>): void {
|
|
32
|
+
for (const [token, value] of Object.entries(theme)) {
|
|
33
|
+
if (!value || !value.trim()) continue
|
|
34
|
+
document.documentElement.style.setProperty(`--${token}`, value)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove a single inline override, reverting the token to its
|
|
40
|
+
* injected/`app.css` value.
|
|
41
|
+
*/
|
|
42
|
+
export function resetToken(token: string): void {
|
|
43
|
+
document.documentElement.style.removeProperty(`--${token}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Remove inline overrides for the given tokens (default: the exposed list),
|
|
48
|
+
* reverting them to their injected/`app.css` values.
|
|
49
|
+
*/
|
|
50
|
+
export function resetAll(tokens: string[] = THEME_TOKENS.map((t) => t.token)): void {
|
|
51
|
+
for (const token of tokens) {
|
|
52
|
+
resetToken(token)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the current effective value of a token from `:root` — already including
|
|
58
|
+
* any server-injected override. The editor seeds each picker from this, so an
|
|
59
|
+
* un-set token always equals today's look without hardcoded defaults.
|
|
60
|
+
*/
|
|
61
|
+
export function readCurrent(token: string): string {
|
|
62
|
+
return getComputedStyle(document.documentElement).getPropertyValue(`--${token}`).trim()
|
|
63
|
+
}
|
package/src/lib/types/schema.ts
CHANGED
|
@@ -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((
|
|
35
|
+
beforeNavigate((cancel) => {
|
|
36
36
|
if (bypass) return
|
|
37
37
|
if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) {
|
|
38
38
|
cancel()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Appearance — super-admin theming page.
|
|
4
|
+
*
|
|
5
|
+
* Renders one color picker per exposed THEME_TOKENS entry. Editing a token
|
|
6
|
+
* writes an inline `:root` override for instant live preview (applyTheme) and
|
|
7
|
+
* marks the token as overridden. Save persists only the overridden tokens via
|
|
8
|
+
* PUT /api/appearance. Because the canonical theme is injected server-side at
|
|
9
|
+
* document load, a successful Save shows a persistent reload notice — reloading
|
|
10
|
+
* re-fetches and applies the new colors across the whole admin UI.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { onMount } from 'svelte'
|
|
14
|
+
import { toast } from 'svelte-sonner'
|
|
15
|
+
import { getMe } from '$lib/api/auth.js'
|
|
16
|
+
import { getAppearance, putAppearance } from '$lib/api/appearance.js'
|
|
17
|
+
import { goto, resolve } from '$lib/router/index.svelte.js'
|
|
18
|
+
import {
|
|
19
|
+
THEME_TOKENS,
|
|
20
|
+
applyTheme,
|
|
21
|
+
resetToken,
|
|
22
|
+
resetAll,
|
|
23
|
+
readCurrent,
|
|
24
|
+
} from '$lib/stores/theme.svelte.js'
|
|
25
|
+
|
|
26
|
+
let isLoading = $state(true)
|
|
27
|
+
let isSaving = $state(false)
|
|
28
|
+
let saved = $state(false)
|
|
29
|
+
|
|
30
|
+
// Per-token editor state, keyed by token name.
|
|
31
|
+
let values = $state<Record<string, string>>({})
|
|
32
|
+
let overridden = $state<Record<string, boolean>>({})
|
|
33
|
+
|
|
34
|
+
onMount(async () => {
|
|
35
|
+
// Defense in depth: the sidebar tab is gated and the backend enforces 403,
|
|
36
|
+
// but a direct URL navigation must still bounce non-super-admins.
|
|
37
|
+
const me = await getMe()
|
|
38
|
+
if (!me.ok || me.data.role !== 'super-admin') {
|
|
39
|
+
goto(resolve('/'))
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = await getAppearance()
|
|
44
|
+
const savedMap = result.ok ? result.data : {}
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
toast.error(result.error)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const { token } of THEME_TOKENS) {
|
|
50
|
+
values[token] = savedMap[token] ?? readCurrent(token)
|
|
51
|
+
overridden[token] = token in savedMap
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isLoading = false
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function handleChange(token: string, value: string) {
|
|
58
|
+
values[token] = value
|
|
59
|
+
overridden[token] = true
|
|
60
|
+
saved = false
|
|
61
|
+
applyTheme({ [token]: value })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function handleResetToken(token: string) {
|
|
65
|
+
resetToken(token)
|
|
66
|
+
overridden[token] = false
|
|
67
|
+
saved = false
|
|
68
|
+
values[token] = readCurrent(token)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleResetAll() {
|
|
72
|
+
resetAll()
|
|
73
|
+
saved = false
|
|
74
|
+
for (const { token } of THEME_TOKENS) {
|
|
75
|
+
overridden[token] = false
|
|
76
|
+
values[token] = readCurrent(token)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handleSave() {
|
|
81
|
+
isSaving = true
|
|
82
|
+
|
|
83
|
+
const map: Record<string, string> = {}
|
|
84
|
+
for (const { token } of THEME_TOKENS) {
|
|
85
|
+
if (overridden[token]) map[token] = values[token]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = await putAppearance(map)
|
|
89
|
+
isSaving = false
|
|
90
|
+
|
|
91
|
+
if (result.ok) {
|
|
92
|
+
saved = true
|
|
93
|
+
toast.success('Color scheme saved')
|
|
94
|
+
} else {
|
|
95
|
+
toast.error(result.error)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
{#if isLoading}
|
|
101
|
+
<div class="flex h-full items-center justify-center">
|
|
102
|
+
<p class="text-muted-foreground">Loading…</p>
|
|
103
|
+
</div>
|
|
104
|
+
{:else}
|
|
105
|
+
<header class="sticky top-0 z-10 border-b border-border bg-background">
|
|
106
|
+
<div class="flex items-center justify-between px-7 py-3.5">
|
|
107
|
+
<div class="flex min-w-0 items-center gap-2.5 text-[13px] text-muted-foreground">
|
|
108
|
+
<span class="font-medium text-foreground">Appearance</span>
|
|
109
|
+
</div>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onclick={handleSave}
|
|
113
|
+
disabled={isSaving}
|
|
114
|
+
class="bg-primary px-4 py-2 text-xs font-semibold tracking-wide text-primary-foreground rounded-lg shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60"
|
|
115
|
+
>
|
|
116
|
+
{isSaving ? 'Saving…' : 'Save'}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div class="px-7 pb-5 pt-5">
|
|
121
|
+
<h1 class="font-display text-[28px] font-semibold leading-tight tracking-tight text-foreground">
|
|
122
|
+
Appearance
|
|
123
|
+
</h1>
|
|
124
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
125
|
+
Customize the admin UI brand colors. Changes preview live here and apply across the
|
|
126
|
+
whole admin UI after a reload.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
</header>
|
|
130
|
+
|
|
131
|
+
<div class="px-7 py-8">
|
|
132
|
+
<div class="max-w-2xl space-y-6">
|
|
133
|
+
{#if saved}
|
|
134
|
+
<div class="flex items-center justify-between gap-4 rounded-lg border border-border bg-secondary px-4 py-3">
|
|
135
|
+
<p class="text-sm text-foreground">
|
|
136
|
+
Color scheme saved. Reload to apply it across the admin UI.
|
|
137
|
+
</p>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onclick={() => window.location.reload()}
|
|
141
|
+
class="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold tracking-wide text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
|
142
|
+
>
|
|
143
|
+
Reload
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
147
|
+
|
|
148
|
+
<div class="rounded-lg border border-border bg-card">
|
|
149
|
+
{#each THEME_TOKENS as { token, label }, i}
|
|
150
|
+
<div class="flex items-center gap-4 px-5 py-4 {i > 0 ? 'border-t border-border' : ''}">
|
|
151
|
+
<div class="min-w-0 flex-1">
|
|
152
|
+
<p class="text-sm font-medium text-foreground">{label}</p>
|
|
153
|
+
<p class="text-xs text-muted-foreground">
|
|
154
|
+
{overridden[token] ? 'Custom' : 'Default'}
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="flex items-center gap-2">
|
|
158
|
+
<input
|
|
159
|
+
type="color"
|
|
160
|
+
value={/^#[0-9a-fA-F]{6}$/.test(values[token]) ? values[token] : '#000000'}
|
|
161
|
+
oninput={(e) => handleChange(token, (e.target as HTMLInputElement).value)}
|
|
162
|
+
class="h-10 w-12 cursor-pointer rounded border border-border p-1"
|
|
163
|
+
aria-label="{label} color"
|
|
164
|
+
/>
|
|
165
|
+
<input
|
|
166
|
+
type="text"
|
|
167
|
+
value={values[token]}
|
|
168
|
+
oninput={(e) => handleChange(token, (e.target as HTMLInputElement).value)}
|
|
169
|
+
class="h-10 w-32 rounded-md border border-border bg-background px-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
|
|
170
|
+
placeholder="#000000"
|
|
171
|
+
maxlength={7}
|
|
172
|
+
aria-label="{label} hex value"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onclick={() => handleResetToken(token)}
|
|
178
|
+
disabled={!overridden[token]}
|
|
179
|
+
class="shrink-0 rounded-md border border-border px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:opacity-50"
|
|
180
|
+
>
|
|
181
|
+
Reset
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
{/each}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div class="flex items-center justify-between">
|
|
188
|
+
<p class="text-xs text-muted-foreground">
|
|
189
|
+
Colors apply across the admin UI on the next page reload.
|
|
190
|
+
</p>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onclick={handleResetAll}
|
|
194
|
+
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
|
195
|
+
>
|
|
196
|
+
Reset all to defaults
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
{/if}
|
|
@@ -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}`), {
|
|
28
|
+
await goto(resolve(`/${collectionKey}/${result.data.id}`), { replace: true })
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
})
|