@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.
- package/LICENSE +661 -0
- package/biome.json +62 -0
- package/dist/assets/index-C9Y5-AKj.js +33 -0
- package/dist/assets/index-uVdiUjty.css +1 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/package.json +43 -0
- package/src/AdminRoot.svelte +98 -0
- package/src/app.css +211 -0
- package/src/lib/Slot.svelte +65 -0
- package/src/lib/api/auth.ts +26 -0
- package/src/lib/api/client.ts +73 -0
- package/src/lib/api/files.ts +56 -0
- package/src/lib/api/globals.ts +13 -0
- package/src/lib/api/records.ts +102 -0
- package/src/lib/api/schema.ts +7 -0
- package/src/lib/api/versions.ts +40 -0
- package/src/lib/components/AdminHeader.svelte +107 -0
- package/src/lib/components/AdminSidebar.svelte +262 -0
- package/src/lib/components/DeleteDialog.svelte +58 -0
- package/src/lib/components/DocumentEditLayout.svelte +263 -0
- package/src/lib/components/DynamicForm.svelte +74 -0
- package/src/lib/components/KpiCard.svelte +75 -0
- package/src/lib/components/MediaLibrary.svelte +311 -0
- package/src/lib/components/Pagination.svelte +78 -0
- package/src/lib/components/RangeFilter.svelte +41 -0
- package/src/lib/components/RecordGrid.svelte +123 -0
- package/src/lib/components/RecordTable.svelte +156 -0
- package/src/lib/components/cells/CheckboxCell.svelte +10 -0
- package/src/lib/components/cells/ColorCell.svelte +15 -0
- package/src/lib/components/cells/DateCell.svelte +8 -0
- package/src/lib/components/cells/RelationshipCell.svelte +20 -0
- package/src/lib/components/cells/RichTextCell.svelte +21 -0
- package/src/lib/components/cells/SelectCell.svelte +26 -0
- package/src/lib/components/cells/TextCell.svelte +8 -0
- package/src/lib/components/cells/UploadCell.svelte +34 -0
- package/src/lib/components/cells/index.ts +28 -0
- package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
- package/src/lib/components/doc/ApiView.svelte +181 -0
- package/src/lib/components/doc/Autosave.svelte +102 -0
- package/src/lib/components/doc/DocHeader.svelte +86 -0
- package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
- package/src/lib/components/doc/DocTabBar.svelte +26 -0
- package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
- package/src/lib/components/doc/PublishButton.svelte +114 -0
- package/src/lib/components/doc/ScheduleModal.svelte +110 -0
- package/src/lib/components/doc/VersionHistory.svelte +20 -0
- package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
- package/src/lib/components/fields/BlockCard.svelte +63 -0
- package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
- package/src/lib/components/fields/CheckboxField.svelte +27 -0
- package/src/lib/components/fields/ColorField.svelte +46 -0
- package/src/lib/components/fields/DateField.svelte +52 -0
- package/src/lib/components/fields/EmailField.svelte +30 -0
- package/src/lib/components/fields/FileField.svelte +280 -0
- package/src/lib/components/fields/JsonField.svelte +145 -0
- package/src/lib/components/fields/NumberField.svelte +44 -0
- package/src/lib/components/fields/PasswordField.svelte +38 -0
- package/src/lib/components/fields/RelationshipField.svelte +271 -0
- package/src/lib/components/fields/RichTextField.svelte +139 -0
- package/src/lib/components/fields/SelectField.svelte +33 -0
- package/src/lib/components/fields/SlugField.svelte +70 -0
- package/src/lib/components/fields/TabsField.svelte +56 -0
- package/src/lib/components/fields/TagsField.svelte +85 -0
- package/src/lib/components/fields/TextField.svelte +36 -0
- package/src/lib/components/fields/TextareaField.svelte +32 -0
- package/src/lib/components/fields/UploadField.svelte +166 -0
- package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
- package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
- package/src/lib/components/fields/index.ts +22 -0
- package/src/lib/components/fields/registry.ts +58 -0
- package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
- package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
- package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
- package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
- package/src/lib/components/lexical/lexical-helpers.ts +24 -0
- package/src/lib/components/lexical/nodes.ts +8 -0
- package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
- package/src/lib/components/versions/CompareSelector.svelte +31 -0
- package/src/lib/components/versions/FieldDiff.svelte +141 -0
- package/src/lib/components/versions/RestoreModal.svelte +67 -0
- package/src/lib/components/versions/StatusPill.svelte +21 -0
- package/src/lib/context.svelte.ts +156 -0
- package/src/lib/router/index.svelte.ts +282 -0
- package/src/lib/router/matcher.ts +52 -0
- package/src/lib/stores/branding.svelte.ts +74 -0
- package/src/lib/stores/schema.svelte.ts +17 -0
- package/src/lib/types/schema.ts +126 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/diff.ts +112 -0
- package/src/lib/utils/dirty.svelte.ts +50 -0
- package/src/lib/utils/format.ts +28 -0
- package/src/lib/utils/json-highlight.ts +34 -0
- package/src/lib/utils/slug.ts +8 -0
- package/src/main.ts +32 -0
- package/src/views/AdminLayout.svelte +73 -0
- package/src/views/AdsAnalyticsView.svelte +152 -0
- package/src/views/CollectionEditView.svelte +117 -0
- package/src/views/CollectionListView.svelte +347 -0
- package/src/views/CollectionNewView.svelte +68 -0
- package/src/views/CustomPageView.svelte +59 -0
- package/src/views/DashboardView.svelte +370 -0
- package/src/views/GlobalEditView.svelte +100 -0
- package/src/views/LoginView.svelte +231 -0
- package/src/views/NotFoundView.svelte +9 -0
- package/src/views/VersionDetailView.svelte +307 -0
- package/src/views/VersionsListView.svelte +201 -0
- package/tsconfig.json +25 -0
- 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>
|