@quoin-cms/admin 0.1.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 (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. package/vite.config.ts +80 -0
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ import { highlightJson } from '$lib/utils/json-highlight.js'
3
+ import { Copy, Check, Pencil } from 'lucide-svelte'
4
+ import { toast } from 'svelte-sonner'
5
+
6
+ let {
7
+ formData = $bindable<Record<string, any>>({}),
8
+ collectionKey,
9
+ recordId = null,
10
+ mode,
11
+ onApply,
12
+ }: {
13
+ formData?: Record<string, any>
14
+ collectionKey: string
15
+ recordId?: string | null
16
+ mode: 'create' | 'edit'
17
+ onApply: () => void
18
+ } = $props()
19
+
20
+ let activeTab = $state<'json' | 'curl'>('json')
21
+ let editing = $state(false)
22
+ let draft = $state('')
23
+ let parseError = $state<string | null>(null)
24
+ let parsedDraft: Record<string, any> | null = null
25
+ let copied = $state(false)
26
+
27
+ let prettyJson = $derived(JSON.stringify(formData, null, 2))
28
+ let highlightedJson = $derived(highlightJson(prettyJson))
29
+
30
+ let curlSnippet = $derived.by(() => {
31
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173'
32
+ const url =
33
+ mode === 'create'
34
+ ? `${origin}/api/${collectionKey}`
35
+ : `${origin}/api/${collectionKey}/${recordId}`
36
+ const method = mode === 'create' ? 'POST' : 'PUT'
37
+ const body = JSON.stringify(formData).replace(/'/g, "'\\''")
38
+ return `curl -X ${method} '${url}' \\
39
+ -H 'Content-Type: application/json' \\
40
+ -H 'Cookie: <session>' \\
41
+ -d '${body}'`
42
+ })
43
+
44
+ let highlightedCurl = $derived(highlightJson(curlSnippet))
45
+
46
+ function startEdit() {
47
+ draft = prettyJson
48
+ parseError = null
49
+ parsedDraft = null
50
+ editing = true
51
+ }
52
+
53
+ function onDraftInput() {
54
+ parseError = null
55
+ parsedDraft = null
56
+ try {
57
+ const parsed = JSON.parse(draft)
58
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
59
+ parseError = 'Top-level value must be a JSON object'
60
+ return
61
+ }
62
+ parsedDraft = parsed
63
+ } catch (e) {
64
+ parseError = (e as Error).message
65
+ }
66
+ }
67
+
68
+ function applyChanges() {
69
+ if (!parsedDraft) return
70
+ for (const k of Object.keys(formData)) delete formData[k]
71
+ for (const k of Object.keys(parsedDraft)) formData[k] = parsedDraft[k]
72
+ editing = false
73
+ onApply()
74
+ toast.success('JSON applied')
75
+ }
76
+
77
+ function cancelEdit() {
78
+ editing = false
79
+ draft = ''
80
+ parseError = null
81
+ parsedDraft = null
82
+ }
83
+
84
+ async function copyActive() {
85
+ const text = activeTab === 'json' ? prettyJson : curlSnippet
86
+ try {
87
+ await navigator.clipboard.writeText(text)
88
+ copied = true
89
+ setTimeout(() => (copied = false), 1500)
90
+ } catch {
91
+ toast.error('Copy failed')
92
+ }
93
+ }
94
+ </script>
95
+
96
+ <div class="space-y-4 px-7 py-6">
97
+ <div class="flex items-center justify-between">
98
+ <div class="flex gap-1">
99
+ <button
100
+ type="button"
101
+ onclick={() => (activeTab = 'json')}
102
+ class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
103
+ {activeTab === 'json'
104
+ ? 'bg-secondary text-foreground'
105
+ : 'text-muted-foreground hover:text-foreground'}"
106
+ >
107
+ JSON
108
+ </button>
109
+ <button
110
+ type="button"
111
+ onclick={() => (activeTab = 'curl')}
112
+ class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
113
+ {activeTab === 'curl'
114
+ ? 'bg-secondary text-foreground'
115
+ : 'text-muted-foreground hover:text-foreground'}"
116
+ >
117
+ cURL
118
+ </button>
119
+ </div>
120
+ <div class="flex items-center gap-2">
121
+ {#if activeTab === 'json' && !editing}
122
+ <button
123
+ type="button"
124
+ onclick={startEdit}
125
+ class="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-sm hover:bg-secondary"
126
+ >
127
+ <Pencil class="h-3.5 w-3.5" />
128
+ Edit JSON
129
+ </button>
130
+ {/if}
131
+ <button
132
+ type="button"
133
+ onclick={copyActive}
134
+ class="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-sm hover:bg-secondary"
135
+ >
136
+ {#if copied}
137
+ <Check class="h-3.5 w-3.5 text-emerald-700" />
138
+ Copied
139
+ {:else}
140
+ <Copy class="h-3.5 w-3.5" />
141
+ Copy
142
+ {/if}
143
+ </button>
144
+ </div>
145
+ </div>
146
+
147
+ {#if activeTab === 'json'}
148
+ {#if editing}
149
+ <textarea
150
+ bind:value={draft}
151
+ oninput={onDraftInput}
152
+ spellcheck="false"
153
+ class="block min-h-[420px] w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-[12px] leading-relaxed text-foreground shadow-sm focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
154
+ ></textarea>
155
+ {#if parseError}
156
+ <p class="text-xs text-destructive">Invalid JSON: {parseError}</p>
157
+ {/if}
158
+ <div class="flex items-center gap-2">
159
+ <button
160
+ type="button"
161
+ onclick={applyChanges}
162
+ disabled={!parsedDraft}
163
+ class="rounded-lg bg-primary px-4 py-2 text-xs font-semibold text-primary-foreground shadow-sm hover:bg-primary/90 disabled:opacity-50"
164
+ >
165
+ Apply changes
166
+ </button>
167
+ <button
168
+ type="button"
169
+ onclick={cancelEdit}
170
+ class="rounded-lg border border-border bg-card px-4 py-2 text-xs text-foreground hover:bg-secondary"
171
+ >
172
+ Cancel
173
+ </button>
174
+ </div>
175
+ {:else}
176
+ <pre class="overflow-auto rounded-lg border border-border bg-card p-4 font-mono text-[12px] leading-relaxed text-foreground shadow-sm">{@html highlightedJson}</pre>
177
+ {/if}
178
+ {:else}
179
+ <pre class="overflow-auto rounded-lg border border-border bg-card p-4 font-mono text-[12px] leading-relaxed text-foreground shadow-sm">{@html highlightedCurl}</pre>
180
+ {/if}
181
+ </div>
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { updateRecord } from '$lib/api/records.js'
3
+
4
+ let {
5
+ collectionKey,
6
+ recordId,
7
+ formData,
8
+ interval = 2000,
9
+ }: {
10
+ collectionKey: string
11
+ recordId: string | null
12
+ formData: Record<string, any>
13
+ interval?: number
14
+ } = $props()
15
+
16
+ let lastSaved = $state<Date | null>(null)
17
+ let isSaving = $state(false)
18
+
19
+ // --- Layer 1: Mount guard (skip first render) ---
20
+ let mounted = false
21
+
22
+ // --- Layer 2: Snapshot for deep comparison (plain vars, not reactive) ---
23
+ // Strip volatile fields that change on every save (like Payload strips updatedAt)
24
+ function snapshot(data: Record<string, any>): string {
25
+ const { updatedAt, createdAt, _versionId, _versionCreatedAt, ...rest } = data
26
+ return JSON.stringify(rest)
27
+ }
28
+ // svelte-ignore state_referenced_locally
29
+ let previousSnapshot = snapshot(formData)
30
+
31
+ // --- Layer 3: Debounce timer ---
32
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
33
+
34
+ // --- Layer 4: Queue — only one save in flight, drop intermediaries ---
35
+ let pendingSave: (() => Promise<void>) | null = null
36
+
37
+ async function drainQueue() {
38
+ while (pendingSave) {
39
+ const task = pendingSave
40
+ pendingSave = null
41
+ await task()
42
+ }
43
+ }
44
+
45
+ function enqueue(task: () => Promise<void>) {
46
+ pendingSave = task // always overwrite — only latest matters
47
+ if (!isSaving) drainQueue()
48
+ }
49
+
50
+ // --- Main effect: watch formData, debounce, compare, enqueue ---
51
+ $effect(() => {
52
+ const current = snapshot(formData)
53
+
54
+ // Skip initial mount (Payload's didMount guard)
55
+ if (!mounted) {
56
+ mounted = true
57
+ return
58
+ }
59
+
60
+ // No actual change (deep comparison on stripped data)
61
+ if (current === previousSnapshot) return
62
+ if (!recordId) return
63
+
64
+ // Debounce: reset timer on every change, fire after idle period
65
+ if (timeoutId) clearTimeout(timeoutId)
66
+
67
+ timeoutId = setTimeout(() => {
68
+ // Re-snapshot after debounce — coalesce rapid changes
69
+ const final = snapshot(formData)
70
+ if (final === previousSnapshot) return
71
+
72
+ previousSnapshot = final
73
+
74
+ enqueue(async () => {
75
+ isSaving = true
76
+ const result = await updateRecord(collectionKey, recordId!, formData, {
77
+ draft: true,
78
+ autosave: true,
79
+ })
80
+ isSaving = false
81
+ if (result.ok) {
82
+ lastSaved = new Date()
83
+ }
84
+ })
85
+ }, interval)
86
+ })
87
+
88
+ // --- Time ago display ---
89
+ let timeAgo = $derived.by(() => {
90
+ if (!lastSaved) return ''
91
+ const secs = Math.floor((Date.now() - lastSaved.getTime()) / 1000)
92
+ if (secs < 5) return 'just now'
93
+ if (secs < 60) return `${secs}s ago`
94
+ return `${Math.floor(secs / 60)}m ago`
95
+ })
96
+ </script>
97
+
98
+ {#if lastSaved}
99
+ <span class="text-[11px] text-muted-foreground">
100
+ {isSaving ? 'Saving...' : `Autosaved ${timeAgo}`}
101
+ </span>
102
+ {/if}
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import { resolve } from '$lib/router/index.svelte.js'
3
+ import DocMetaStrip from './DocMetaStrip.svelte'
4
+ import DocTabBar from './DocTabBar.svelte'
5
+ import HeaderModeSwitch from './HeaderModeSwitch.svelte'
6
+ import type { CollectionSchema } from '$lib/types/schema.js'
7
+
8
+ let {
9
+ collection,
10
+ liveTitle,
11
+ record,
12
+ mode: docMode,
13
+ status,
14
+ saveLabel,
15
+ isDirty,
16
+ isSubmitting,
17
+ onSave,
18
+ onSaveAs,
19
+ onUnpublish,
20
+ onSchedule,
21
+ tabs,
22
+ activeTab = $bindable(0),
23
+ headerMode,
24
+ setHeaderMode,
25
+ hasDrafts = false,
26
+ hasSchedulePublish = false,
27
+ metaExtra,
28
+ }: {
29
+ collection: CollectionSchema
30
+ liveTitle: string
31
+ record: Record<string, any> | null
32
+ mode: 'create' | 'edit'
33
+ status: string
34
+ saveLabel: string
35
+ isDirty: boolean
36
+ isSubmitting: boolean
37
+ onSave: () => void
38
+ onSaveAs: (status: 'draft' | 'published' | 'archived') => void
39
+ onUnpublish?: () => void
40
+ onSchedule?: () => void
41
+ tabs: { label: string }[]
42
+ activeTab?: number
43
+ headerMode: 'edit' | 'api'
44
+ setHeaderMode: (m: 'edit' | 'api') => void
45
+ hasDrafts?: boolean
46
+ hasSchedulePublish?: boolean
47
+ metaExtra?: import('svelte').Snippet
48
+ } = $props()
49
+ </script>
50
+
51
+ <header class="sticky top-0 z-10 border-b border-border bg-background">
52
+ <!-- Row 1: breadcrumb + avatar -->
53
+ <div class="flex items-center justify-between border-b border-stone-200/60 px-7 py-3.5">
54
+ <div class="flex min-w-0 items-center gap-2.5 text-[13px] text-muted-foreground">
55
+ <a href={resolve(`/${collection.key}`)} class="font-medium hover:text-foreground">
56
+ {collection.labelPlural || collection.label}
57
+ </a>
58
+ <span class="text-stone-300">/</span>
59
+ <span class="truncate font-medium text-foreground">{liveTitle}</span>
60
+ </div>
61
+ {#if collection.versions && record?.id}
62
+ <a
63
+ href={resolve(`/${collection.key}/${record.id}/versions`)}
64
+ class="inline-flex items-center gap-1.5 rounded-lg border border-border/80 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
65
+ >
66
+ Versions
67
+ </a>
68
+ {/if}
69
+ <HeaderModeSwitch mode={headerMode} setMode={setHeaderMode} />
70
+ </div>
71
+
72
+ <!-- Row 2: H1 -->
73
+ <div class="px-7 pb-2 pt-5">
74
+ <h1 class="font-display text-[28px] font-semibold leading-tight tracking-tight text-foreground">
75
+ {liveTitle}
76
+ </h1>
77
+ </div>
78
+
79
+ <!-- Row 3: meta + actions -->
80
+ <DocMetaStrip {record} mode={docMode} {status} {saveLabel} {isDirty} {isSubmitting} {hasDrafts} {hasSchedulePublish} onSave={onSave} onSaveAs={onSaveAs} {onUnpublish} {onSchedule} {metaExtra} />
81
+
82
+ <!-- Row 4: tabs -->
83
+ {#if headerMode === 'edit'}
84
+ <DocTabBar {tabs} bind:activeIndex={activeTab} />
85
+ {/if}
86
+ </header>
@@ -0,0 +1,103 @@
1
+ <script lang="ts">
2
+ import PublishButton from './PublishButton.svelte'
3
+ import { ExternalLink, MoreVertical } from 'lucide-svelte'
4
+ import { formatDate } from '$lib/utils/format.js'
5
+
6
+ import type { Snippet } from 'svelte'
7
+
8
+ let {
9
+ record,
10
+ mode,
11
+ status,
12
+ saveLabel,
13
+ isDirty,
14
+ isSubmitting,
15
+ hasDrafts = false,
16
+ hasSchedulePublish = false,
17
+ onSave,
18
+ onSaveAs,
19
+ onUnpublish,
20
+ onSchedule,
21
+ metaExtra,
22
+ }: {
23
+ record: Record<string, any> | null
24
+ mode: 'create' | 'edit'
25
+ status: string
26
+ saveLabel: string
27
+ isDirty: boolean
28
+ isSubmitting: boolean
29
+ hasDrafts?: boolean
30
+ hasSchedulePublish?: boolean
31
+ onSave: () => void
32
+ onSaveAs: (status: 'draft' | 'published' | 'archived') => void
33
+ onUnpublish?: () => void
34
+ onSchedule?: () => void
35
+ metaExtra?: Snippet
36
+ } = $props()
37
+
38
+ const statusColors: Record<string, string> = {
39
+ draft: 'bg-amber-100 text-amber-800 border border-amber-200',
40
+ published: 'bg-emerald-100 text-emerald-800 border border-emerald-200',
41
+ archived: 'bg-stone-200 text-stone-700 border border-stone-300',
42
+ scheduled: 'bg-yellow-100 text-yellow-800 border border-yellow-200',
43
+ }
44
+ let badgeClass = $derived(statusColors[status?.toLowerCase()] ?? 'bg-stone-100 text-stone-700 border border-stone-200')
45
+ let statusLabel = $derived(status ? status.charAt(0).toUpperCase() + status.slice(1) : '—')
46
+ </script>
47
+
48
+ <div class="flex flex-wrap items-center justify-between gap-4 px-7 pb-4 pt-3.5">
49
+ <div class="flex flex-wrap items-center gap-5 text-xs text-muted-foreground">
50
+ {#if hasDrafts}
51
+ <span class="flex items-center gap-2">
52
+ <span class="text-[11px] uppercase tracking-wider text-stone-400">Status:</span>
53
+ <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-semibold {badgeClass}">
54
+ <span class="h-1.5 w-1.5 rounded-full bg-current opacity-70"></span>
55
+ {statusLabel}
56
+ </span>
57
+ </span>
58
+ {/if}
59
+ {#if mode === 'edit' && record}
60
+ {#if record.updatedAt}
61
+ <span>
62
+ <span class="text-stone-400">Last Modified:</span>
63
+ <span class="ml-1.5 font-medium text-stone-700">{formatDate(record.updatedAt)}</span>
64
+ </span>
65
+ {/if}
66
+ {#if record.createdAt}
67
+ <span>
68
+ <span class="text-stone-400">Created:</span>
69
+ <span class="ml-1.5 font-medium text-stone-700">{formatDate(record.createdAt)}</span>
70
+ </span>
71
+ {/if}
72
+ {/if}
73
+ {#if metaExtra}{@render metaExtra()}{/if}
74
+ </div>
75
+ <div class="flex items-center gap-2">
76
+ {#if hasDrafts && status === 'published' && mode !== 'create' && onUnpublish}
77
+ <button
78
+ type="button"
79
+ onclick={onUnpublish}
80
+ class="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
81
+ >
82
+ Unpublish
83
+ </button>
84
+ {/if}
85
+ <button
86
+ type="button"
87
+ disabled
88
+ class="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-stone-500 opacity-50 shadow-sm"
89
+ title="Open in new tab (coming soon)"
90
+ >
91
+ <ExternalLink class="h-4 w-4" />
92
+ </button>
93
+ <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>
102
+ </div>
103
+ </div>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ let {
3
+ tabs,
4
+ activeIndex = $bindable(0),
5
+ }: {
6
+ tabs: { label: string }[]
7
+ activeIndex?: number
8
+ } = $props()
9
+ </script>
10
+
11
+ {#if tabs.length > 0}
12
+ <div class="flex gap-0.5 border-b border-border px-7">
13
+ {#each tabs as tab, i (i)}
14
+ <button
15
+ type="button"
16
+ onclick={() => (activeIndex = i)}
17
+ class="-mb-px border-b-2 px-4 py-3 text-[13px] font-medium transition-colors
18
+ {activeIndex === i
19
+ ? 'border-primary text-primary'
20
+ : 'border-transparent text-muted-foreground hover:text-foreground'}"
21
+ >
22
+ {tab.label}
23
+ </button>
24
+ {/each}
25
+ </div>
26
+ {/if}
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ let {
3
+ mode,
4
+ setMode,
5
+ }: {
6
+ mode: 'edit' | 'api'
7
+ setMode: (m: 'edit' | 'api') => void
8
+ } = $props()
9
+ </script>
10
+
11
+ <div class="inline-flex items-stretch overflow-hidden rounded-lg border border-border bg-card shadow-sm">
12
+ <button
13
+ type="button"
14
+ onclick={() => setMode('edit')}
15
+ class="px-3 py-1.5 text-xs font-medium transition-colors
16
+ {mode === 'edit'
17
+ ? 'bg-primary text-primary-foreground'
18
+ : 'text-stone-600 hover:bg-secondary'}"
19
+ >
20
+ Edit
21
+ </button>
22
+ <button
23
+ type="button"
24
+ onclick={() => setMode('api')}
25
+ class="border-l border-border px-3 py-1.5 text-xs font-medium transition-colors
26
+ {mode === 'api'
27
+ ? 'bg-primary text-primary-foreground'
28
+ : 'text-stone-600 hover:bg-secondary'}"
29
+ >
30
+ API
31
+ </button>
32
+ </div>
@@ -0,0 +1,114 @@
1
+ <script lang="ts">
2
+ import { ChevronDown } from 'lucide-svelte'
3
+
4
+ let {
5
+ label,
6
+ currentStatus,
7
+ isSubmitting = false,
8
+ isDirty = false,
9
+ disabled = false,
10
+ hasSchedulePublish = false,
11
+ onSave,
12
+ onSaveAs,
13
+ onSchedule,
14
+ }: {
15
+ label: string
16
+ currentStatus: string
17
+ isSubmitting?: boolean
18
+ isDirty?: boolean
19
+ disabled?: boolean
20
+ hasSchedulePublish?: boolean
21
+ onSave: () => void
22
+ onSaveAs: (status: 'draft' | 'published' | 'archived') => void
23
+ onSchedule?: () => void
24
+ } = $props()
25
+
26
+ let menuOpen = $state(false)
27
+ let menuRef = $state<HTMLDivElement | null>(null)
28
+
29
+ function toggleMenu() {
30
+ menuOpen = !menuOpen
31
+ }
32
+
33
+ function selectStatus(status: 'draft' | 'published' | 'archived') {
34
+ menuOpen = false
35
+ onSaveAs(status)
36
+ }
37
+
38
+ function onWindowClick(e: MouseEvent) {
39
+ if (!menuOpen) return
40
+ if (menuRef && !menuRef.contains(e.target as Node)) {
41
+ menuOpen = false
42
+ }
43
+ }
44
+ </script>
45
+
46
+ <svelte:window onclick={onWindowClick} />
47
+
48
+ <div class="inline-flex items-center gap-2">
49
+ {#if isDirty}
50
+ <span class="inline-flex items-center gap-1.5 text-[11px] text-amber-700">
51
+ <span class="h-1.5 w-1.5 rounded-full bg-amber-600"></span>
52
+ Unsaved
53
+ </span>
54
+ {/if}
55
+ <div class="relative" bind:this={menuRef}>
56
+ <div class="inline-flex items-stretch overflow-hidden rounded-lg shadow-sm">
57
+ <button
58
+ type="button"
59
+ disabled={disabled || isSubmitting}
60
+ onclick={onSave}
61
+ class="bg-primary px-4 py-2 text-xs font-semibold tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
62
+ >
63
+ {isSubmitting ? 'Saving…' : label}
64
+ </button>
65
+ <button
66
+ type="button"
67
+ disabled={disabled || isSubmitting}
68
+ onclick={toggleMenu}
69
+ class="flex items-center border-l border-primary-foreground/15 bg-primary/90 px-2 text-primary-foreground hover:bg-primary disabled:opacity-60"
70
+ aria-label="Publish options"
71
+ aria-expanded={menuOpen}
72
+ >
73
+ <ChevronDown class="h-3.5 w-3.5" />
74
+ </button>
75
+ </div>
76
+ {#if menuOpen}
77
+ <div class="absolute right-0 z-20 mt-1 w-44 overflow-hidden rounded-lg border border-border bg-card shadow-lg">
78
+ <button
79
+ type="button"
80
+ onclick={() => selectStatus('draft')}
81
+ class="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-foreground hover:bg-secondary"
82
+ >
83
+ Save as draft
84
+ {#if currentStatus === 'draft'}<span class="text-primary">●</span>{/if}
85
+ </button>
86
+ <button
87
+ type="button"
88
+ onclick={() => selectStatus('published')}
89
+ class="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-foreground hover:bg-secondary"
90
+ >
91
+ Publish
92
+ {#if currentStatus === 'published'}<span class="text-primary">●</span>{/if}
93
+ </button>
94
+ {#if hasSchedulePublish && onSchedule}
95
+ <button
96
+ type="button"
97
+ onclick={() => { menuOpen = false; onSchedule?.() }}
98
+ class="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-foreground hover:bg-secondary"
99
+ >
100
+ Schedule Publish
101
+ </button>
102
+ {/if}
103
+ <button
104
+ type="button"
105
+ onclick={() => selectStatus('archived')}
106
+ class="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-foreground hover:bg-secondary"
107
+ >
108
+ Archive
109
+ {#if currentStatus === 'archived'}<span class="text-primary">●</span>{/if}
110
+ </button>
111
+ </div>
112
+ {/if}
113
+ </div>
114
+ </div>