@velastack/cms 0.4.0 → 0.5.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/README.md +107 -18
- package/dist/backend/index.d.ts +1 -1
- package/dist/backend/routes/seed.d.ts +6 -0
- package/dist/backend/routes/seed.js +96 -0
- package/dist/backend/routes/table.js +3 -0
- package/dist/backend/store/queries.d.ts +6 -1
- package/dist/backend/store/queries.js +89 -0
- package/dist/cms.js +13 -16
- package/dist/components/admin-bar/admin-bar-internal.svelte +4 -4
- package/dist/components/admin-bar/admin-bar.css +1 -1
- package/dist/components/admin-bar/field-input.svelte +220 -0
- package/dist/components/admin-bar/field-input.svelte.d.ts +29 -0
- package/dist/components/admin-bar/icons/pencil.svelte +19 -0
- package/dist/components/admin-bar/icons/pencil.svelte.d.ts +3 -0
- package/dist/components/admin-bar/keyboard-shortcuts-dialog.svelte +1 -1
- package/dist/components/admin-bar/locales-panel.svelte +42 -1
- package/dist/components/admin-bar/page-config.d.ts +8 -1
- package/dist/components/admin-bar/page-config.js +15 -2
- package/dist/components/admin-bar/reorder-buttons.svelte +52 -0
- package/dist/components/admin-bar/reorder-buttons.svelte.d.ts +10 -0
- package/dist/components/admin-bar/seo-panel.svelte +144 -189
- package/dist/components/admin-bar/site-options-panel.svelte +115 -0
- package/dist/components/admin-bar/site-options-panel.svelte.d.ts +7 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte +250 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte.d.ts +48 -0
- package/dist/components/admin-bar/ui/checkbox/checkbox.svelte +36 -0
- package/dist/components/admin-bar/ui/checkbox/checkbox.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/checkbox/index.d.ts +2 -0
- package/dist/components/admin-bar/ui/checkbox/index.js +2 -0
- package/dist/components/admin-bar/ui/popover/index.d.ts +5 -0
- package/dist/components/admin-bar/ui/popover/index.js +7 -0
- package/dist/components/admin-bar/ui/popover/popover-close.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover-close.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/popover/popover-content.svelte +35 -0
- package/dist/components/admin-bar/ui/popover/popover-content.svelte.d.ts +10 -0
- package/dist/components/admin-bar/ui/popover/popover-trigger.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/popover/popover.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover.svelte.d.ts +3 -0
- package/dist/components/admin-bar/ui/sheet/index.d.ts +10 -0
- package/dist/components/admin-bar/ui/sheet/index.js +12 -0
- package/dist/components/admin-bar/ui/sheet/sheet-close.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet-close.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-content.svelte +64 -0
- package/dist/components/admin-bar/ui/sheet/sheet-content.svelte.d.ts +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-description.svelte +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-description.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte +23 -0
- package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/sheet/sheet-header.svelte +20 -0
- package/dist/components/admin-bar/ui/sheet/sheet-header.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte +20 -0
- package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-title.svelte +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-title.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet.svelte.d.ts +3 -0
- package/dist/components/admin-bar/ui/switch/index.d.ts +2 -0
- package/dist/components/admin-bar/ui/switch/index.js +2 -0
- package/dist/components/admin-bar/ui/switch/switch.svelte +27 -0
- package/dist/components/admin-bar/ui/switch/switch.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/tabs/index.d.ts +5 -0
- package/dist/components/admin-bar/ui/tabs/index.js +7 -0
- package/dist/components/admin-bar/ui/tabs/tabs-content.svelte +17 -0
- package/dist/components/admin-bar/ui/tabs/tabs-content.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs-list.svelte +16 -0
- package/dist/components/admin-bar/ui/tabs/tabs-list.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte +20 -0
- package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs.svelte +19 -0
- package/dist/components/admin-bar/ui/tabs/tabs.svelte.d.ts +4 -0
- package/dist/components/cms/cms-boolean.svelte +9 -18
- package/dist/components/cms/cms-boolean.svelte.d.ts +1 -0
- package/dist/components/cms/cms-date-time-editable.svelte +3 -3
- package/dist/components/cms/cms-date-time.svelte +17 -17
- package/dist/components/cms/cms-date-time.svelte.d.ts +2 -1
- package/dist/components/cms/cms-image-editable.svelte +13 -4
- package/dist/components/cms/cms-image.svelte +37 -30
- package/dist/components/cms/cms-image.svelte.d.ts +11 -4
- package/dist/components/cms/cms-link.svelte +26 -19
- package/dist/components/cms/cms-link.svelte.d.ts +2 -0
- package/dist/components/cms/cms-markdown.svelte +15 -16
- package/dist/components/cms/cms-markdown.svelte.d.ts +1 -0
- package/dist/components/cms/cms-number-editable.svelte +4 -2
- package/dist/components/cms/cms-number.svelte +18 -17
- package/dist/components/cms/cms-number.svelte.d.ts +2 -1
- package/dist/components/cms/cms-repeater.svelte +15 -18
- package/dist/components/cms/cms-repeater.svelte.d.ts +1 -0
- package/dist/components/cms/cms-rich-text.svelte +17 -22
- package/dist/components/cms/cms-rich-text.svelte.d.ts +1 -0
- package/dist/components/cms/cms-store.svelte.d.ts +14 -0
- package/dist/components/cms/cms-store.svelte.js +39 -2
- package/dist/components/cms/cms-text.svelte +23 -30
- package/dist/components/cms/cms-text.svelte.d.ts +3 -1
- package/dist/components/cms/scope.d.ts +25 -5
- package/dist/components/cms/structured-registry.d.ts +10 -0
- package/dist/components/cms/structured-registry.js +6 -0
- package/dist/components/cms/use-cms-field.svelte.d.ts +54 -0
- package/dist/components/cms/use-cms-field.svelte.js +83 -0
- package/dist/core/metadata.d.ts +44 -0
- package/dist/core/metadata.js +59 -0
- package/dist/core/structured.d.ts +108 -0
- package/dist/core/structured.js +184 -0
- package/dist/core/wire.d.ts +31 -1
- package/dist/editor.d.ts +22 -0
- package/dist/editor.js +21 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.js +13 -3
- package/dist/server/load-cms.js +1 -0
- package/package.json +12 -3
- package/plugin/README.md +22 -2
- package/plugin/dist/build.d.ts +74 -0
- package/plugin/dist/build.js +210 -0
- package/plugin/dist/build.js.map +1 -0
- package/plugin/dist/index.d.ts +10 -1
- package/plugin/dist/index.js +63 -4
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/manifest.d.ts +31 -0
- package/plugin/dist/manifest.js +146 -41
- package/plugin/dist/manifest.js.map +1 -1
- package/plugin/dist/parse-svelte.d.ts +15 -0
- package/plugin/dist/parse-svelte.js +26 -16
- package/plugin/dist/parse-svelte.js.map +1 -1
- package/dist/components/admin-bar/site-settings-panel.svelte +0 -225
- package/dist/components/admin-bar/site-settings-panel.svelte.d.ts +0 -7
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
index: number;
|
|
3
|
+
count: number;
|
|
4
|
+
onMove: (from: number, to: number) => void;
|
|
5
|
+
onRemove: (index: number) => void;
|
|
6
|
+
label?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const ReorderButtons: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type ReorderButtons = ReturnType<typeof ReorderButtons>;
|
|
10
|
+
export default ReorderButtons;
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
import { pages } from 'virtual:vela-cms/pages';
|
|
5
5
|
import { cmsStore, type CmsScopeRef } from '../cms/cms-store.svelte.js';
|
|
6
6
|
import { getPageScope, type CmsPayload } from '../cms/scope.js';
|
|
7
|
+
import { ROOT_SCOPE_ID } from '../cms/use-cms-field.svelte.js';
|
|
8
|
+
import { toMetaTags } from '../../core/metadata.js';
|
|
9
|
+
import FieldInput, { type FieldInputType } from './field-input.svelte';
|
|
7
10
|
import {
|
|
8
|
-
DEFAULT_METADATA_SCHEMA,
|
|
9
11
|
fieldType,
|
|
12
|
+
metadataSchemaFor,
|
|
10
13
|
type CmsPageMetadataSchema,
|
|
11
14
|
type MetadataFieldSchema
|
|
12
15
|
} from './page-config.js';
|
|
@@ -15,8 +18,7 @@
|
|
|
15
18
|
import Panel from './panel.svelte';
|
|
16
19
|
import { Button } from './ui/button/index.js';
|
|
17
20
|
import * as Collapsible from './ui/collapsible/index.js';
|
|
18
|
-
import
|
|
19
|
-
import { Textarea } from './ui/textarea/index.js';
|
|
21
|
+
import * as Tabs from './ui/tabs/index.js';
|
|
20
22
|
|
|
21
23
|
type Props = { onClose: () => void; onSave: () => void | Promise<void> };
|
|
22
24
|
let { onClose, onSave }: Props = $props();
|
|
@@ -27,6 +29,20 @@
|
|
|
27
29
|
const TITLE_AMBER_AT = Math.floor(TITLE_TARGET * 0.8);
|
|
28
30
|
const DESC_AMBER_AT = Math.floor(DESC_TARGET * 0.8);
|
|
29
31
|
|
|
32
|
+
// The search snippet and the share card; everything else is advanced.
|
|
33
|
+
const PRIMARY = ['title', 'description'];
|
|
34
|
+
const SHARE = ['ogTitle', 'ogDescription', 'ogImage', 'twitterCard'];
|
|
35
|
+
const LABELS: Record<string, string> = {
|
|
36
|
+
title: 'Title',
|
|
37
|
+
description: 'Description',
|
|
38
|
+
ogTitle: 'Share title',
|
|
39
|
+
ogDescription: 'Share description',
|
|
40
|
+
ogImage: 'Share image',
|
|
41
|
+
twitterCard: 'Twitter card',
|
|
42
|
+
canonical: 'Canonical URL',
|
|
43
|
+
noindex: 'Hide from search engines'
|
|
44
|
+
};
|
|
45
|
+
|
|
30
46
|
const cms = $derived(page.data.cms as CmsPayload | undefined);
|
|
31
47
|
const pageScope = $derived(getPageScope(cms));
|
|
32
48
|
const ref = $derived<CmsScopeRef | null>(
|
|
@@ -35,25 +51,37 @@
|
|
|
35
51
|
: null
|
|
36
52
|
);
|
|
37
53
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
54
|
+
// The route's `page.cms.ts` schema merged over the defaults, so every
|
|
55
|
+
// route gets the search snippet, the share card and indexing controls.
|
|
40
56
|
const schema = $derived.by<CmsPageMetadataSchema>(() => {
|
|
41
57
|
if (!pageScope) return {};
|
|
42
|
-
|
|
43
|
-
return cfg?.metadata ?? DEFAULT_METADATA_SCHEMA;
|
|
58
|
+
return metadataSchemaFor(pages[pageScope.routeId]?.metadata);
|
|
44
59
|
});
|
|
45
60
|
|
|
46
61
|
const entries = $derived(Object.entries(schema));
|
|
47
|
-
const
|
|
62
|
+
const shareEntries = $derived(entries.filter(([k]) => SHARE.includes(k)));
|
|
63
|
+
const advancedEntries = $derived(
|
|
64
|
+
entries.filter(([k]) => !PRIMARY.includes(k) && !SHARE.includes(k))
|
|
65
|
+
);
|
|
48
66
|
|
|
49
67
|
const persistedValue = (key: string): unknown => {
|
|
50
68
|
if (!ref) return undefined;
|
|
51
69
|
return cmsStore.getValue(ref, `metadata.${key}`);
|
|
52
70
|
};
|
|
53
71
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
72
|
+
// The title template reads the root layout's branding name, exactly as
|
|
73
|
+
// `toMetaTags` does at render time.
|
|
74
|
+
const siteName = $derived.by(() => {
|
|
75
|
+
const v = cmsStore.getValue(
|
|
76
|
+
{ scopeId: ROOT_SCOPE_ID, routeId: '/', params: {} },
|
|
77
|
+
'branding.name'
|
|
78
|
+
);
|
|
79
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Local form state — typing only updates `local`; nothing reaches the
|
|
83
|
+
// draft until Save fires. That keeps the bar's "X page draft" badge from
|
|
84
|
+
// flickering as the user explores.
|
|
57
85
|
let local = $state<Record<string, unknown>>({});
|
|
58
86
|
|
|
59
87
|
$effect(() => {
|
|
@@ -62,8 +90,17 @@
|
|
|
62
90
|
local = next;
|
|
63
91
|
});
|
|
64
92
|
|
|
65
|
-
const
|
|
66
|
-
const
|
|
93
|
+
const stringValue = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
94
|
+
const titleVal = $derived(stringValue(local.title));
|
|
95
|
+
const descVal = $derived(stringValue(local.description));
|
|
96
|
+
const preview = $derived(toMetaTags(local, { siteName }));
|
|
97
|
+
const fullTitle = $derived(
|
|
98
|
+
preview.title
|
|
99
|
+
? preview.titleTemplate
|
|
100
|
+
? preview.titleTemplate.replace('%s', preview.title)
|
|
101
|
+
: preview.title
|
|
102
|
+
: ''
|
|
103
|
+
);
|
|
67
104
|
|
|
68
105
|
const breadcrumb = $derived.by(() => {
|
|
69
106
|
const host = page.url.hostname || 'localhost';
|
|
@@ -71,12 +108,10 @@
|
|
|
71
108
|
return segments.length === 0 ? host : `${host} › ${segments.join(' › ')}`;
|
|
72
109
|
});
|
|
73
110
|
|
|
74
|
-
let previewTab = $state
|
|
111
|
+
let previewTab = $state('search');
|
|
75
112
|
|
|
76
113
|
// Pull the live site's favicon — Google's result preview shows whichever
|
|
77
|
-
// favicon the document head declares, so the SEO preview should match.
|
|
78
|
-
// to absolute URLs (using `.href` rather than `.getAttribute`) so the img src
|
|
79
|
-
// renders correctly regardless of where the favicon is hosted.
|
|
114
|
+
// favicon the document head declares, so the SEO preview should match.
|
|
80
115
|
let faviconHref = $state<string | null>(null);
|
|
81
116
|
$effect(() => {
|
|
82
117
|
if (typeof document === 'undefined') return;
|
|
@@ -86,21 +121,6 @@
|
|
|
86
121
|
faviconHref = link?.href ?? null;
|
|
87
122
|
});
|
|
88
123
|
|
|
89
|
-
// Best-effort OG image: an explicit `og_image` field in the schema wins,
|
|
90
|
-
// otherwise the first image-type metadata field. The Social card stays useful
|
|
91
|
-
// even when neither exists — title/description still render against a
|
|
92
|
-
// placeholder.
|
|
93
|
-
const ogImageVal = $derived.by(() => {
|
|
94
|
-
const og = stringValue(local.og_image);
|
|
95
|
-
if (og) return og;
|
|
96
|
-
for (const [k, s] of entries) {
|
|
97
|
-
if (fieldType(s) !== 'image') continue;
|
|
98
|
-
const v = stringValue(local[k]);
|
|
99
|
-
if (v) return v;
|
|
100
|
-
}
|
|
101
|
-
return '';
|
|
102
|
-
});
|
|
103
|
-
|
|
104
124
|
const hostname = $derived(page.url.hostname || 'localhost');
|
|
105
125
|
|
|
106
126
|
const counterClass = (n: number, target: number, amberAt: number): string => {
|
|
@@ -118,33 +138,25 @@
|
|
|
118
138
|
await onSave();
|
|
119
139
|
};
|
|
120
140
|
|
|
121
|
-
const fieldLabelClass = 'vela:text-[12px] vela:text-bar-text-secondary';
|
|
122
141
|
const counterBaseClass = 'vela:text-[11px] vela:font-mono vela:tabular-nums';
|
|
123
|
-
const selectClass =
|
|
124
|
-
'vela:flex vela:h-9 vela:w-full vela:rounded-md vela:border vela:border-[var(--cms-bar-divider)]' +
|
|
125
|
-
' vela:bg-[var(--cms-bar-bg-hover)] vela:px-3 vela:py-1 vela:text-[13px] vela:text-bar-text' +
|
|
126
|
-
' vela:focus:outline-none vela:focus:ring-2 vela:focus:ring-[var(--cms-accent)]';
|
|
127
142
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const booleanValue = (v: unknown): boolean => v === true;
|
|
132
|
-
|
|
133
|
-
const setNumber = (key: string, raw: string) => {
|
|
134
|
-
if (raw === '') {
|
|
135
|
-
local[key] = undefined;
|
|
136
|
-
} else {
|
|
137
|
-
const n = Number(raw);
|
|
138
|
-
local[key] = Number.isFinite(n) ? n : undefined;
|
|
139
|
-
}
|
|
143
|
+
const inputType = (s: MetadataFieldSchema): FieldInputType => {
|
|
144
|
+
const t = fieldType(s);
|
|
145
|
+
return t === 'string' ? 'text' : t;
|
|
140
146
|
};
|
|
141
|
-
|
|
142
147
|
const enumValues = (s: MetadataFieldSchema): readonly string[] =>
|
|
143
148
|
typeof s !== 'string' && s.type === 'enum' ? s.values : [];
|
|
149
|
+
const labelFor = (key: string) => LABELS[key] ?? key;
|
|
144
150
|
</script>
|
|
145
151
|
|
|
146
152
|
<Panel ariaLabel="SEO and metadata" {onClose}>
|
|
147
|
-
<PanelHeader title="SEO & metadata" {onClose}
|
|
153
|
+
<PanelHeader title="SEO & metadata" {onClose}>
|
|
154
|
+
{#snippet subtitle()}
|
|
155
|
+
<span class="vela:font-mono vela:text-[12px] vela:text-bar-text-secondary">
|
|
156
|
+
{page.url.pathname}
|
|
157
|
+
</span>
|
|
158
|
+
{/snippet}
|
|
159
|
+
</PanelHeader>
|
|
148
160
|
|
|
149
161
|
<form
|
|
150
162
|
class="vela:overflow-y-auto vela:px-4 vela:pb-4 vela:flex vela:flex-col vela:gap-4"
|
|
@@ -158,42 +170,13 @@
|
|
|
158
170
|
No page scope on this route.
|
|
159
171
|
</p>
|
|
160
172
|
{:else}
|
|
161
|
-
<
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
vela:bg-[var(--cms-bar-bg-hover)] vela:border vela:border-[var(--cms-bar-divider)]"
|
|
167
|
-
>
|
|
168
|
-
<button
|
|
169
|
-
type="button"
|
|
170
|
-
role="tab"
|
|
171
|
-
aria-selected={previewTab === 'search'}
|
|
172
|
-
onclick={() => (previewTab = 'search')}
|
|
173
|
-
class="vela:px-2.5 vela:py-1 vela:rounded vela:text-[11px] vela:font-medium
|
|
174
|
-
vela:cursor-pointer vela:transition-colors
|
|
175
|
-
{previewTab === 'search'
|
|
176
|
-
? 'vela:bg-bar-bg vela:text-bar-text'
|
|
177
|
-
: 'vela:text-bar-text-secondary vela:hover:text-bar-text'}"
|
|
178
|
-
>
|
|
179
|
-
Search
|
|
180
|
-
</button>
|
|
181
|
-
<button
|
|
182
|
-
type="button"
|
|
183
|
-
role="tab"
|
|
184
|
-
aria-selected={previewTab === 'social'}
|
|
185
|
-
onclick={() => (previewTab = 'social')}
|
|
186
|
-
class="vela:px-2.5 vela:py-1 vela:rounded vela:text-[11px] vela:font-medium
|
|
187
|
-
vela:cursor-pointer vela:transition-colors
|
|
188
|
-
{previewTab === 'social'
|
|
189
|
-
? 'vela:bg-bar-bg vela:text-bar-text'
|
|
190
|
-
: 'vela:text-bar-text-secondary vela:hover:text-bar-text'}"
|
|
191
|
-
>
|
|
192
|
-
Social
|
|
193
|
-
</button>
|
|
194
|
-
</div>
|
|
173
|
+
<Tabs.Root bind:value={previewTab}>
|
|
174
|
+
<Tabs.List aria-label="Preview format">
|
|
175
|
+
<Tabs.Trigger value="search">Search</Tabs.Trigger>
|
|
176
|
+
<Tabs.Trigger value="social">Social</Tabs.Trigger>
|
|
177
|
+
</Tabs.List>
|
|
195
178
|
|
|
196
|
-
|
|
179
|
+
<Tabs.Content value="search">
|
|
197
180
|
<div
|
|
198
181
|
class="vela:bg-[#fafafa] vela:text-[#202124] vela:rounded-lg vela:p-3.5
|
|
199
182
|
vela:border vela:border-[#e8eaed]"
|
|
@@ -215,13 +198,15 @@
|
|
|
215
198
|
<div
|
|
216
199
|
class="vela:mt-1.5 vela:text-[18px] vela:leading-snug vela:text-[#1a0dab] vela:truncate"
|
|
217
200
|
>
|
|
218
|
-
{
|
|
201
|
+
{fullTitle || 'Page title'}
|
|
219
202
|
</div>
|
|
220
203
|
<div class="vela:mt-1 vela:text-[13px] vela:leading-snug vela:text-[#4d5156]">
|
|
221
|
-
{
|
|
204
|
+
{preview.description || 'Meta description appears here.'}
|
|
222
205
|
</div>
|
|
223
206
|
</div>
|
|
224
|
-
|
|
207
|
+
</Tabs.Content>
|
|
208
|
+
|
|
209
|
+
<Tabs.Content value="social">
|
|
225
210
|
<div
|
|
226
211
|
class="vela:bg-white vela:text-[#0f1419] vela:rounded-lg vela:overflow-hidden
|
|
227
212
|
vela:border vela:border-[#e8eaed]"
|
|
@@ -229,70 +214,92 @@
|
|
|
229
214
|
<div
|
|
230
215
|
class="vela:aspect-[1.91/1] vela:bg-[#e8eaed] vela:flex vela:items-center vela:justify-center vela:overflow-hidden"
|
|
231
216
|
>
|
|
232
|
-
{#if
|
|
233
|
-
<img
|
|
217
|
+
{#if preview.openGraph?.images?.[0]}
|
|
218
|
+
<img
|
|
219
|
+
src={preview.openGraph.images[0].url}
|
|
220
|
+
alt=""
|
|
221
|
+
class="vela:w-full vela:h-full vela:object-cover"
|
|
222
|
+
/>
|
|
234
223
|
{:else}
|
|
235
|
-
<span class="vela:text-[11px] vela:text-[#5f6368]">No
|
|
224
|
+
<span class="vela:text-[11px] vela:text-[#5f6368]">No share image set</span>
|
|
236
225
|
{/if}
|
|
237
226
|
</div>
|
|
238
227
|
<div
|
|
239
228
|
class="vela:px-3.5 vela:py-2.5 vela:border-t vela:border-[#e8eaed] vela:bg-[#f7f9fa]"
|
|
240
229
|
>
|
|
241
230
|
<div class="vela:text-[11px] vela:uppercase vela:text-[#5f6368]">
|
|
242
|
-
{hostname}
|
|
231
|
+
{siteName ?? hostname}
|
|
243
232
|
</div>
|
|
244
233
|
<div
|
|
245
234
|
class="vela:mt-0.5 vela:text-[14px] vela:font-semibold vela:leading-snug vela:line-clamp-2"
|
|
246
235
|
>
|
|
247
|
-
{
|
|
236
|
+
{preview.openGraph?.title || 'Page title'}
|
|
248
237
|
</div>
|
|
249
238
|
<div
|
|
250
239
|
class="vela:mt-0.5 vela:text-[12px] vela:leading-snug vela:text-[#536471] vela:line-clamp-2"
|
|
251
240
|
>
|
|
252
|
-
{
|
|
241
|
+
{preview.openGraph?.description || 'Meta description appears here.'}
|
|
253
242
|
</div>
|
|
254
243
|
</div>
|
|
255
244
|
</div>
|
|
256
|
-
|
|
257
|
-
</
|
|
245
|
+
</Tabs.Content>
|
|
246
|
+
</Tabs.Root>
|
|
258
247
|
|
|
259
248
|
{#if 'title' in schema}
|
|
260
|
-
<
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
</div>
|
|
273
|
-
<Input
|
|
274
|
-
type="text"
|
|
275
|
-
value={stringValue(local.title)}
|
|
276
|
-
oninput={(e) => (local.title = e.currentTarget.value)}
|
|
277
|
-
/>
|
|
278
|
-
</label>
|
|
249
|
+
<FieldInput
|
|
250
|
+
type="text"
|
|
251
|
+
label="Title"
|
|
252
|
+
value={local.title}
|
|
253
|
+
onChange={(v) => (local.title = v ?? '')}
|
|
254
|
+
hint={`${titleVal.length} / ${TITLE_TARGET}`}
|
|
255
|
+
hintClass="{counterBaseClass} {counterClass(
|
|
256
|
+
titleVal.length,
|
|
257
|
+
TITLE_TARGET,
|
|
258
|
+
TITLE_AMBER_AT
|
|
259
|
+
)}"
|
|
260
|
+
/>
|
|
279
261
|
{/if}
|
|
280
262
|
|
|
281
263
|
{#if 'description' in schema}
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
264
|
+
<FieldInput
|
|
265
|
+
type="long-string"
|
|
266
|
+
label="Description"
|
|
267
|
+
value={local.description}
|
|
268
|
+
onChange={(v) => (local.description = v ?? '')}
|
|
269
|
+
hint={`${descVal.length} / ${DESC_TARGET}`}
|
|
270
|
+
hintClass="{counterBaseClass} {counterClass(descVal.length, DESC_TARGET, DESC_AMBER_AT)}"
|
|
271
|
+
/>
|
|
272
|
+
{/if}
|
|
273
|
+
|
|
274
|
+
{#if shareEntries.length > 0}
|
|
275
|
+
<Collapsible.Root class="vela:border-t vela:border-[var(--cms-bar-divider)] vela:pt-3">
|
|
276
|
+
<Collapsible.Trigger
|
|
277
|
+
class="vela:group vela:flex vela:items-center vela:justify-between vela:w-full
|
|
278
|
+
vela:text-left vela:text-[13px] vela:text-bar-text-secondary
|
|
279
|
+
vela:hover:text-bar-text vela:cursor-pointer vela:focus:outline-none"
|
|
280
|
+
>
|
|
281
|
+
<span>Share card</span>
|
|
282
|
+
<ChevronDownIcon
|
|
283
|
+
class="vela:size-4 vela:transition-transform vela:group-data-[state=open]:rotate-180"
|
|
284
|
+
/>
|
|
285
|
+
</Collapsible.Trigger>
|
|
286
|
+
<Collapsible.Content>
|
|
287
|
+
<div class="vela:flex vela:flex-col vela:gap-3 vela:mt-3">
|
|
288
|
+
<p class="vela:text-[11px] vela:text-bar-text-tertiary vela:m-0">
|
|
289
|
+
Blank fields reuse the title and description above.
|
|
290
|
+
</p>
|
|
291
|
+
{#each shareEntries as [key, fieldSchema] (key)}
|
|
292
|
+
<FieldInput
|
|
293
|
+
type={inputType(fieldSchema)}
|
|
294
|
+
label={labelFor(key)}
|
|
295
|
+
values={enumValues(fieldSchema)}
|
|
296
|
+
value={local[key]}
|
|
297
|
+
onChange={(v) => (local[key] = v)}
|
|
298
|
+
/>
|
|
299
|
+
{/each}
|
|
300
|
+
</div>
|
|
301
|
+
</Collapsible.Content>
|
|
302
|
+
</Collapsible.Root>
|
|
296
303
|
{/if}
|
|
297
304
|
|
|
298
305
|
{#if advancedEntries.length > 0}
|
|
@@ -310,65 +317,13 @@
|
|
|
310
317
|
<Collapsible.Content>
|
|
311
318
|
<div class="vela:flex vela:flex-col vela:gap-3 vela:mt-3">
|
|
312
319
|
{#each advancedEntries as [key, fieldSchema] (key)}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
checked={booleanValue(local[key])}
|
|
321
|
-
onchange={(e) => (local[key] = e.currentTarget.checked)}
|
|
322
|
-
/>
|
|
323
|
-
<span>{key}</span>
|
|
324
|
-
</label>
|
|
325
|
-
{:else}
|
|
326
|
-
<label class="vela:flex vela:flex-col vela:gap-1.5">
|
|
327
|
-
<span class={fieldLabelClass}>{key}</span>
|
|
328
|
-
{#if t === 'long-string'}
|
|
329
|
-
<Textarea
|
|
330
|
-
value={stringValue(local[key])}
|
|
331
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
332
|
-
/>
|
|
333
|
-
{:else if t === 'number'}
|
|
334
|
-
<Input
|
|
335
|
-
type="number"
|
|
336
|
-
value={numberValue(local[key]) ?? ''}
|
|
337
|
-
oninput={(e) => setNumber(key, e.currentTarget.value)}
|
|
338
|
-
/>
|
|
339
|
-
{:else if t === 'date'}
|
|
340
|
-
<Input
|
|
341
|
-
type="date"
|
|
342
|
-
value={stringValue(local[key])}
|
|
343
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
344
|
-
/>
|
|
345
|
-
{:else if t === 'image'}
|
|
346
|
-
<Input
|
|
347
|
-
type="url"
|
|
348
|
-
placeholder="https://…"
|
|
349
|
-
value={stringValue(local[key])}
|
|
350
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
351
|
-
/>
|
|
352
|
-
{:else if t === 'enum'}
|
|
353
|
-
<select
|
|
354
|
-
class={selectClass}
|
|
355
|
-
value={stringValue(local[key])}
|
|
356
|
-
onchange={(e) => (local[key] = e.currentTarget.value)}
|
|
357
|
-
>
|
|
358
|
-
<option value="">—</option>
|
|
359
|
-
{#each enumValues(fieldSchema) as v (v)}
|
|
360
|
-
<option value={v}>{v}</option>
|
|
361
|
-
{/each}
|
|
362
|
-
</select>
|
|
363
|
-
{:else}
|
|
364
|
-
<Input
|
|
365
|
-
type="text"
|
|
366
|
-
value={stringValue(local[key])}
|
|
367
|
-
oninput={(e) => (local[key] = e.currentTarget.value)}
|
|
368
|
-
/>
|
|
369
|
-
{/if}
|
|
370
|
-
</label>
|
|
371
|
-
{/if}
|
|
320
|
+
<FieldInput
|
|
321
|
+
type={inputType(fieldSchema)}
|
|
322
|
+
label={labelFor(key)}
|
|
323
|
+
values={enumValues(fieldSchema)}
|
|
324
|
+
value={local[key]}
|
|
325
|
+
onChange={(v) => (local[key] = v)}
|
|
326
|
+
/>
|
|
372
327
|
{/each}
|
|
373
328
|
</div>
|
|
374
329
|
</Collapsible.Content>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Site Options: the few project-wide values that do not render on the
|
|
4
|
+
* page — the schema.org type, the default share image, which locales are
|
|
5
|
+
* enabled. Anything a visitor sees (hours, contact, navigation, branding)
|
|
6
|
+
* lives in the root layout scope and is edited where it renders.
|
|
7
|
+
*
|
|
8
|
+
* The schema comes from `createCms({ site })`; storage is one
|
|
9
|
+
* non-localised tree per project.
|
|
10
|
+
*/
|
|
11
|
+
import { page } from '$app/state';
|
|
12
|
+
import { cmsStore } from '../cms/cms-store.svelte.js';
|
|
13
|
+
import type { CmsPayload, SiteFieldSchema, SiteSchema } from '../cms/scope.js';
|
|
14
|
+
import FieldInput from './field-input.svelte';
|
|
15
|
+
import PanelFooter from './panel-footer.svelte';
|
|
16
|
+
import PanelHeader from './panel-header.svelte';
|
|
17
|
+
import Panel from './panel.svelte';
|
|
18
|
+
import { Button } from './ui/button/index.js';
|
|
19
|
+
|
|
20
|
+
type Props = { onClose: () => void; onSave: () => void | Promise<void> };
|
|
21
|
+
let { onClose, onSave }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const cms = $derived(page.data.cms as CmsPayload | undefined);
|
|
24
|
+
const schema = $derived<SiteSchema>(cms?.site?.schema ?? {});
|
|
25
|
+
const endpoint = $derived(cms?.endpoint ?? '/api/cms');
|
|
26
|
+
|
|
27
|
+
// Flat (path → schema) view for save/reset; sections preserved for layout.
|
|
28
|
+
const sections = $derived(Object.entries(schema));
|
|
29
|
+
const allFieldPaths = $derived.by(() => {
|
|
30
|
+
const out: Array<{ path: string; schema: SiteFieldSchema }> = [];
|
|
31
|
+
for (const [sectionKey, section] of sections) {
|
|
32
|
+
for (const [fieldKey, fieldSchema] of Object.entries(section.fields)) {
|
|
33
|
+
out.push({ path: `${sectionKey}.${fieldKey}`, schema: fieldSchema });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const persistedValue = (path: string): unknown => cmsStore.getSiteValue(path);
|
|
40
|
+
|
|
41
|
+
// Local form state — typing only updates `local`; nothing reaches
|
|
42
|
+
// `cmsStore.siteDraft` until Save fires (matches seo-panel.svelte).
|
|
43
|
+
let local = $state<Record<string, unknown>>({});
|
|
44
|
+
|
|
45
|
+
$effect(() => {
|
|
46
|
+
const next: Record<string, unknown> = {};
|
|
47
|
+
for (const { path } of allFieldPaths) next[path] = persistedValue(path);
|
|
48
|
+
local = next;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const handleSave = async () => {
|
|
52
|
+
for (const { path } of allFieldPaths) {
|
|
53
|
+
const v = local[path];
|
|
54
|
+
if (v !== persistedValue(path)) cmsStore.setSiteValue(path, v);
|
|
55
|
+
}
|
|
56
|
+
await onSave();
|
|
57
|
+
};
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<Panel ariaLabel="Site options" {onClose}>
|
|
61
|
+
<PanelHeader title="Site options" {onClose}>
|
|
62
|
+
{#snippet subtitle()}
|
|
63
|
+
<span class="vela:text-[12px] vela:text-bar-text-secondary">
|
|
64
|
+
settings that don't render on the page
|
|
65
|
+
</span>
|
|
66
|
+
{/snippet}
|
|
67
|
+
</PanelHeader>
|
|
68
|
+
|
|
69
|
+
<form
|
|
70
|
+
class="vela:overflow-y-auto vela:px-4 vela:pb-4 vela:flex vela:flex-col vela:gap-5"
|
|
71
|
+
onsubmit={(e) => {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
handleSave();
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{#if sections.length === 0}
|
|
77
|
+
<p class="vela:text-[13px] vela:text-bar-text-tertiary vela:py-2">
|
|
78
|
+
No site options have been declared. Pass a <code>site</code> schema to
|
|
79
|
+
<code>createCms()</code> to add fields here. Text that renders on the page belongs in the root
|
|
80
|
+
layout, edited in place.
|
|
81
|
+
</p>
|
|
82
|
+
{:else}
|
|
83
|
+
{#each sections as [sectionKey, section] (sectionKey)}
|
|
84
|
+
<section class="vela:flex vela:flex-col vela:gap-3">
|
|
85
|
+
<h3
|
|
86
|
+
class="vela:text-[10px] vela:font-semibold vela:uppercase vela:tracking-widest vela:text-bar-text-tertiary vela:m-0"
|
|
87
|
+
>
|
|
88
|
+
{section.label}
|
|
89
|
+
</h3>
|
|
90
|
+
|
|
91
|
+
{#each Object.entries(section.fields) as [fieldKey, fieldSchema] (fieldKey)}
|
|
92
|
+
{@const path = `${sectionKey}.${fieldKey}`}
|
|
93
|
+
<FieldInput
|
|
94
|
+
type={fieldSchema.type}
|
|
95
|
+
label={fieldSchema.label}
|
|
96
|
+
placeholder={fieldSchema.placeholder}
|
|
97
|
+
values={fieldSchema.values ?? []}
|
|
98
|
+
value={local[path]}
|
|
99
|
+
onChange={(v) => (local[path] = v)}
|
|
100
|
+
{endpoint}
|
|
101
|
+
/>
|
|
102
|
+
{/each}
|
|
103
|
+
</section>
|
|
104
|
+
{/each}
|
|
105
|
+
{/if}
|
|
106
|
+
</form>
|
|
107
|
+
|
|
108
|
+
<PanelFooter>
|
|
109
|
+
<span>Save to keep your changes</span>
|
|
110
|
+
<div class="vela:flex vela:items-center vela:gap-1.5">
|
|
111
|
+
<Button variant="ghost" size="pill" onclick={onClose}>Cancel</Button>
|
|
112
|
+
<Button size="pill" onclick={handleSave}>Save</Button>
|
|
113
|
+
</div>
|
|
114
|
+
</PanelFooter>
|
|
115
|
+
</Panel>
|