@quoin-cms/admin 0.5.0 → 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 CHANGED
@@ -11,6 +11,7 @@
11
11
  rel="stylesheet"
12
12
  />
13
13
  <title>Quoin Admin</title>
14
+ <style id="quoin-theme">/*__QUOIN_THEME__*/</style>
14
15
  </head>
15
16
  <body>
16
17
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quoin-cms/admin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -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}
@@ -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,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, LayoutDashboard, User, type Icon
15
+ MessageSquare, BarChart3, MousePointerClick, Puzzle, LayoutDashboard, User, Palette, type Icon
16
16
  } from 'lucide-svelte'
17
17
  import type { Component, ComponentType, SvelteComponent } from 'svelte'
18
18
 
@@ -227,6 +227,29 @@ async function handleLogout() {
227
227
  </ul>
228
228
  </div>
229
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}
230
253
  </nav>
231
254
 
232
255
  <!-- Footer -->
@@ -3,40 +3,46 @@
3
3
 
4
4
  interface Props {
5
5
  field: { name: string; label?: string; labels?: { singular?: string; plural?: string } };
6
- value: any[];
7
- onChange: (next: any[]) => void;
6
+ // Two-way bound by FieldWidget (`bind:value`); writes go straight back.
7
+ value?: any[];
8
8
  }
9
- let { field, value, onChange }: Props = $props();
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
- return crypto.randomUUID(); // browser UUIDv4 server Sanitize will upgrade to v7 if absent
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
- onChange([...rows, { id: genId() }]);
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
- onChange(out);
30
+ value = out;
25
31
  }
26
32
  function remove(i: number) {
27
- onChange(rows.filter((_, j) => j !== i));
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
- onChange(out);
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
- onChange(out);
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: any[];
17
- onChange: (next: any[]) => void;
16
+ // Two-way bound by FieldWidget (`bind:value`); writes go straight back.
17
+ value?: any[];
18
18
  }
19
- let { field, value, onChange }: Props = $props();
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
- return crypto.randomUUID();
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
- onChange([...rows, defaultsFor(pickerSlug)]);
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
- onChange(out);
43
+ value = out;
38
44
  }
39
45
  function remove(i: number) {
40
- onChange(rows.filter((_, j) => j !== i));
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
- onChange(out);
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
- onChange(out);
58
+ value = out;
53
59
  }
54
60
  </script>
55
61
 
@@ -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
+ }
@@ -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}