@acms-subir/core 0.3.16 → 0.3.18
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/CHANGELOG.md +16 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +10 -3
- package/cli/migrations.generated.mjs +71 -1
- package/cli/remote.mjs +1 -1
- package/cli/sync.mjs +876 -0
- package/docs/environment-sync.md +302 -0
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/section-library/upcoming-events/UpcomingEvents.astro +26 -17
- package/section-library/upcoming-events/section.json +41 -0
- package/src/actions/index.ts +5 -1
- package/src/components/CalendarFallbackImage.tsx +18 -0
- package/src/components/DesignAccess.tsx +3 -0
- package/src/components/EventPage.astro +4 -2
- package/src/components/LinksManager.tsx +86 -55
- package/src/components/ThemeSectionsEditor.tsx +13 -1
- package/src/layouts/Site.astro +1 -0
- package/src/lib/calendar/cards.ts +4 -2
- package/src/lib/calendar/images.ts +17 -0
- package/src/lib/editor/schemas.ts +2 -2
- package/src/lib/editor/site-data.ts +5 -3
- package/src/lib/links-avatar.ts +10 -0
- package/src/lib/mobile/links.ts +4 -0
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +26 -0
- package/src/lib/repo/sql.ts +8 -3
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +14 -0
- package/src/lib/sync/auth.ts +24 -0
- package/src/lib/sync/catalog.ts +250 -0
- package/src/lib/sync/migration.ts +51 -0
- package/src/lib/sync/protocol.mjs +125 -0
- package/src/lib/sync/service.ts +994 -0
- package/src/lib/sync/store.ts +349 -0
- package/src/pages/admin/calendar/settings.astro +21 -2
- package/src/pages/admin/integrations.astro +2 -0
- package/src/pages/admin/links.astro +1 -1
- package/src/pages/api/agent/docs.ts +2 -0
- package/src/pages/api/design/keys.ts +7 -3
- package/src/pages/api/sync/index.ts +114 -0
- package/src/pages/api/sync/media.ts +90 -0
- package/src/pages/links.astro +13 -2
- package/src/styles/admin.css +27 -36
- package/src/styles/page-transitions.css +27 -0
- package/src/styles/site.css +3 -1
- package/src/styles/theme-editor.css +47 -12
- package/src/theme-sdk/index.ts +1 -0
|
@@ -12,13 +12,14 @@ import {
|
|
|
12
12
|
import { actions } from 'astro:actions';
|
|
13
13
|
import type { Link, Settings } from '../lib/schema';
|
|
14
14
|
import { ImagePicker } from './editor/ImagePicker';
|
|
15
|
+
import { linksAvatarStyles } from '../lib/links-avatar';
|
|
15
16
|
|
|
16
17
|
// Links manager: profile (name + bio), an "add block" picker with typed blocks
|
|
17
18
|
// (brand-preset links, custom links, email, phone, heading, spacer, text,
|
|
18
19
|
// vcard, raw HTML), drag-to-reorder, inline editing with an icon picker
|
|
19
20
|
// (emoji / image / favicon), and a phone-framed live preview of /links.
|
|
20
21
|
//
|
|
21
|
-
// Edits stay local until "Save
|
|
22
|
+
// Edits stay local until "Save & publish" commits them all at once — the live
|
|
22
23
|
// /links page never sees a half-arranged state. Discard restores the saved
|
|
23
24
|
// state.
|
|
24
25
|
|
|
@@ -119,8 +120,13 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
119
120
|
linksName: settings.linksName || settings.siteTitle,
|
|
120
121
|
linksBio: settings.linksBio || settings.tagline,
|
|
121
122
|
linksAvatar: settings.linksAvatar,
|
|
123
|
+
linksAvatarShape: settings.linksAvatarShape,
|
|
124
|
+
linksAvatarFit: settings.linksAvatarFit,
|
|
125
|
+
linksAvatarScale: settings.linksAvatarScale,
|
|
126
|
+
linksVerified: settings.linksVerified,
|
|
122
127
|
};
|
|
123
128
|
const [profile, setProfile] = useState(initialProfile);
|
|
129
|
+
const avatarStyles = linksAvatarStyles(profile);
|
|
124
130
|
// last state the server has — dirty compares against this
|
|
125
131
|
const [saved, setSaved] = useState({ links: initial, profile: initialProfile });
|
|
126
132
|
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
|
|
@@ -265,55 +271,81 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
265
271
|
))}
|
|
266
272
|
</div>
|
|
267
273
|
<div className="lm-editor">
|
|
268
|
-
<
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
className="lm-avatar"
|
|
273
|
-
title={profile.linksAvatar ? 'Change profile photo' : 'Add profile photo'}
|
|
274
|
-
onClick={() => setAvatarPickerOpen(true)}
|
|
275
|
-
>
|
|
276
|
-
{profile.linksAvatar ? (
|
|
277
|
-
<img src={profile.linksAvatar} alt="" />
|
|
278
|
-
) : (
|
|
279
|
-
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4zM12 11a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
|
|
280
|
-
)}
|
|
281
|
-
<span className="lm-avatar-hint">Edit</span>
|
|
282
|
-
</button>
|
|
283
|
-
{profile.linksAvatar && (
|
|
274
|
+
<section className="lm-profile-card" aria-labelledby="links-profile-title">
|
|
275
|
+
<h2 id="links-profile-title">Profile</h2>
|
|
276
|
+
<div className="lm-profile">
|
|
277
|
+
<div className="lm-avatarwrap">
|
|
284
278
|
<button
|
|
285
279
|
type="button"
|
|
286
|
-
className="lm-avatar
|
|
287
|
-
|
|
288
|
-
|
|
280
|
+
className="lm-avatar"
|
|
281
|
+
style={avatarStyles.frame}
|
|
282
|
+
aria-label={profile.linksAvatar ? 'Change profile photo' : 'Upload profile photo'}
|
|
283
|
+
onClick={() => setAvatarPickerOpen(true)}
|
|
289
284
|
>
|
|
290
|
-
|
|
285
|
+
{profile.linksAvatar ? (
|
|
286
|
+
<img src={profile.linksAvatar} alt="" style={avatarStyles.image} />
|
|
287
|
+
) : (
|
|
288
|
+
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4zM12 11a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
|
|
289
|
+
)}
|
|
291
290
|
</button>
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
291
|
+
<button type="button" className="btn sm" onClick={() => setAvatarPickerOpen(true)}>
|
|
292
|
+
{profile.linksAvatar ? 'Change photo' : 'Upload photo'}
|
|
293
|
+
</button>
|
|
294
|
+
{profile.linksAvatar && (
|
|
295
|
+
<button type="button" className="btn sm ghost" onClick={() => editProfile({ linksAvatar: '' })}>Remove photo</button>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
<div className="lm-profile-fields">
|
|
299
|
+
<fieldset className="lm-photo-controls">
|
|
300
|
+
<legend>Photo appearance</legend>
|
|
301
|
+
<div className="lm-photo-options">
|
|
302
|
+
<label htmlFor="links-avatar-shape">Shape
|
|
303
|
+
<select id="links-avatar-shape" value={profile.linksAvatarShape} onChange={(e) => editProfile({ linksAvatarShape: e.target.value as Settings['linksAvatarShape'] })}>
|
|
304
|
+
<option value="circle">Circle</option>
|
|
305
|
+
<option value="square">Square</option>
|
|
306
|
+
</select>
|
|
307
|
+
</label>
|
|
308
|
+
<label htmlFor="links-avatar-fit">Image fit
|
|
309
|
+
<select id="links-avatar-fit" value={profile.linksAvatarFit} onChange={(e) => editProfile({ linksAvatarFit: e.target.value as Settings['linksAvatarFit'] })}>
|
|
310
|
+
<option value="cover">Fill frame</option>
|
|
311
|
+
<option value="contain">Fit whole image</option>
|
|
312
|
+
</select>
|
|
313
|
+
</label>
|
|
314
|
+
</div>
|
|
315
|
+
<label htmlFor="links-avatar-scale">Image scale <output htmlFor="links-avatar-scale">{profile.linksAvatarScale}%</output></label>
|
|
316
|
+
<input id="links-avatar-scale" type="range" min="25" max="200" step="1" value={profile.linksAvatarScale} aria-describedby="links-avatar-help" onChange={(e) => editProfile({ linksAvatarScale: Number(e.target.value) })} />
|
|
317
|
+
<p id="links-avatar-help">For logos, choose Fit whole image and reduce the scale to leave space around the edges. The thumbnail updates as you adjust.</p>
|
|
318
|
+
<button type="button" className="btn sm ghost" onClick={() => editProfile({ linksAvatarShape: 'circle', linksAvatarFit: 'cover', linksAvatarScale: 100 })}>Reset photo appearance</button>
|
|
319
|
+
</fieldset>
|
|
320
|
+
<label htmlFor="links-profile-name">Name or handle</label>
|
|
321
|
+
<input
|
|
322
|
+
id="links-profile-name"
|
|
323
|
+
type="text"
|
|
324
|
+
className="lm-profile-name"
|
|
325
|
+
placeholder="Your name or @username"
|
|
326
|
+
value={profile.linksName}
|
|
327
|
+
onChange={(e) => editProfile({ linksName: e.target.value })}
|
|
328
|
+
/>
|
|
329
|
+
<label htmlFor="links-profile-bio">Bio</label>
|
|
330
|
+
<textarea
|
|
331
|
+
id="links-profile-bio"
|
|
332
|
+
className="lm-profile-bio"
|
|
333
|
+
rows={2}
|
|
334
|
+
placeholder="Write a short bio…"
|
|
335
|
+
value={profile.linksBio}
|
|
336
|
+
onChange={(e) => {
|
|
337
|
+
e.target.style.height = 'auto';
|
|
338
|
+
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
339
|
+
editProfile({ linksBio: e.target.value });
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
<label className="lm-verification">
|
|
343
|
+
<input type="checkbox" role="switch" checked={profile.linksVerified} onChange={(e) => editProfile({ linksVerified: e.target.checked })} />
|
|
344
|
+
<span>Show verification badge<small>Display a check badge beside your name.</small></span>
|
|
345
|
+
</label>
|
|
346
|
+
</div>
|
|
315
347
|
</div>
|
|
316
|
-
</
|
|
348
|
+
</section>
|
|
317
349
|
|
|
318
350
|
<div className="lm-addwrap">
|
|
319
351
|
<button
|
|
@@ -418,8 +450,9 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
418
450
|
</a>
|
|
419
451
|
</div>
|
|
420
452
|
<div className="lm-phone">
|
|
421
|
-
<iframe key={tick} src="/links" title="
|
|
453
|
+
<iframe key={tick} src="/links" title="Published links page" />
|
|
422
454
|
</div>
|
|
455
|
+
<p className="lm-stat">Published page · refreshes after saving</p>
|
|
423
456
|
<p className="lm-stat"><b>{totalClicks.toLocaleString()}</b> total clicks · <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>{saveLabel}</span></p>
|
|
424
457
|
</aside>
|
|
425
458
|
|
|
@@ -435,15 +468,13 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
435
468
|
))}
|
|
436
469
|
</section>
|
|
437
470
|
|
|
438
|
-
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
</div>
|
|
446
|
-
)}
|
|
471
|
+
<div className="lm-savebar" role="status">
|
|
472
|
+
<span>{saveState === 'error' ? 'Save failed — try again' : dirty ? 'Unsaved changes' : 'Page is live'}</span>
|
|
473
|
+
<button type="button" className="btn sm" onClick={discard} disabled={!dirty || saveState === 'saving'}>Discard</button>
|
|
474
|
+
<button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={!dirty || saveState === 'saving'}>
|
|
475
|
+
{saveState === 'saving' ? 'Saving…' : 'Save & publish'}
|
|
476
|
+
</button>
|
|
477
|
+
</div>
|
|
447
478
|
</div>
|
|
448
479
|
);
|
|
449
480
|
}
|
|
@@ -790,6 +790,7 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
|
|
|
790
790
|
aria-controls={panelId}
|
|
791
791
|
onClick={() => setOpenId(open ? null : s.id)}
|
|
792
792
|
>
|
|
793
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
|
793
794
|
<span className="te-sec-name">{name}</span>
|
|
794
795
|
<span className="te-sec-meta">
|
|
795
796
|
{s.variant !== 'default' && s.variant}
|
|
@@ -927,7 +928,6 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
|
|
|
927
928
|
<button type="button" onClick={() => patchSection(s.id, (x) => ({ ...x, locked: x.locked ? undefined : true }))}>{s.locked ? 'Unlock for editors' : 'Lock for editors'}</button>
|
|
928
929
|
</div>
|
|
929
930
|
</details>
|
|
930
|
-
<button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
|
|
931
931
|
</>}
|
|
932
932
|
{canPublishAlone && changedFromLive && layout.mode === 'sections' && liveLayout?.mode === 'sections' && (
|
|
933
933
|
<button className="btn sm primary te-publish-one" type="button" onClick={() => void publishSection(s)} disabled={saving || conflict || issueCount > 0} title={issueCount ? 'Fix the fields marked below first' : undefined}>
|
|
@@ -935,6 +935,18 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
|
|
|
935
935
|
</button>
|
|
936
936
|
)}
|
|
937
937
|
</div>
|
|
938
|
+
{isOwner && (
|
|
939
|
+
<div className="te-sec-danger" role="group" aria-labelledby={`${panelId}-del`}>
|
|
940
|
+
<p className="te-sec-danger-text">
|
|
941
|
+
<b id={`${panelId}-del`}>Delete section</b>
|
|
942
|
+
<span>Removes {name} from this page’s draft. The live page changes only when you publish.</span>
|
|
943
|
+
</p>
|
|
944
|
+
<button className="btn sm te-delete te-sec-delete" type="button" onClick={() => remove(s)} aria-label={`Delete ${name} section`}>
|
|
945
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
|
|
946
|
+
Delete
|
|
947
|
+
</button>
|
|
948
|
+
</div>
|
|
949
|
+
)}
|
|
938
950
|
</div>
|
|
939
951
|
)}
|
|
940
952
|
</>
|
package/src/layouts/Site.astro
CHANGED
|
@@ -18,6 +18,7 @@ import type { SiteChrome } from '../lib/site-chrome';
|
|
|
18
18
|
import SiteLogo from '../components/site/SiteLogo.astro';
|
|
19
19
|
import ClerkSession from '../components/auth/ClerkSession.astro';
|
|
20
20
|
import '../styles/site.css';
|
|
21
|
+
import '../styles/page-transitions.css';
|
|
21
22
|
import '../styles/cards.css';
|
|
22
23
|
import '../styles/live.css';
|
|
23
24
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ContentRepository } from '../repo/types';
|
|
5
5
|
import type { CalEvent, Settings } from '../schema';
|
|
6
6
|
import { siteLocale } from '../dates';
|
|
7
|
+
import { eventImage } from './images';
|
|
7
8
|
import { eventUrl } from './config';
|
|
8
9
|
import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
|
|
9
10
|
import { collectViewEvents } from './data';
|
|
@@ -36,9 +37,10 @@ export function eventCardHtml(
|
|
|
36
37
|
const h = opts.heading ?? 'h3';
|
|
37
38
|
// "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25"
|
|
38
39
|
const when = f.range;
|
|
40
|
+
const image = eventImage(e, settings);
|
|
39
41
|
const media = opts.showImage
|
|
40
|
-
?
|
|
41
|
-
? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(
|
|
42
|
+
? image.src
|
|
43
|
+
? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(image.src)}" alt="${esc(image.alt)}" loading="lazy" /></a>`
|
|
42
44
|
: `<a class="pc-ev-card-media is-tile" href="${esc(href)}" tabindex="-1" aria-hidden="true"><span class="pc-ev-tile"><b>${esc(f.month)}</b><span>${esc(f.day)}</span></span></a>`
|
|
43
45
|
: '';
|
|
44
46
|
const loc = locationLine(e.location);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CalEvent, Settings } from '../schema';
|
|
2
|
+
|
|
3
|
+
/** Only image URLs that can be placed in an img src; blank clears the fallback. */
|
|
4
|
+
export function safeEventImageUrl(value: unknown): string {
|
|
5
|
+
const url = typeof value === 'string' ? value.trim() : '';
|
|
6
|
+
if (!url || /[\\\x00-\x20]/.test(url)) return '';
|
|
7
|
+
if (/^\/(?!\/)/.test(url)) return url;
|
|
8
|
+
try { return /^https?:$/.test(new URL(url).protocol) ? url : ''; } catch { return ''; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Authored image → section override → calendar fallback. Never modifies the event. */
|
|
12
|
+
export function eventImage(event: Pick<CalEvent, 'cover' | 'coverAlt'>, settings: Pick<Settings, 'calendar'>, sectionFallback = '') {
|
|
13
|
+
const cover = safeEventImageUrl(event.cover);
|
|
14
|
+
return cover
|
|
15
|
+
? { src: cover, alt: event.coverAlt }
|
|
16
|
+
: { src: safeEventImageUrl(sectionFallback) || safeEventImageUrl(settings.calendar.eventFallbackImage), alt: '' };
|
|
17
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { FormDefinition } from '../forms/schema';
|
|
3
|
-
import { Booking, BookingLink, Calendar, LinkKind, Resource } from '../schema';
|
|
3
|
+
import { Booking, BookingLink, Calendar, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, Resource } from '../schema';
|
|
4
4
|
|
|
5
5
|
// Response shapes of the editor-app API (OpenAPI components, client types).
|
|
6
6
|
const str = z.string(), bool = z.boolean(), num = z.number(), date = str.datetime();
|
|
@@ -115,7 +115,7 @@ export const EditorFormResponses = z.object({
|
|
|
115
115
|
items: z.array(z.object({ id: str, createdAt: date, answers: z.array(z.object({ label: str, value: str })) })),
|
|
116
116
|
pagination: Pagination,
|
|
117
117
|
});
|
|
118
|
-
export const EditorLinksPage = z.object({ revision: num.int(), name: str, bio: str, avatar: str, canEditHtml: bool, items: z.array(z.object({
|
|
118
|
+
export const EditorLinksPage = z.object({ revision: num.int(), name: str, bio: str, avatar: str, avatarShape: LinksAvatarShape, avatarFit: LinksAvatarFit, avatarScale: LinksAvatarScale, verified: bool, canEditHtml: bool, items: z.array(z.object({
|
|
119
119
|
id: str, kind: LinkKind, label: str, url: str, icon: str, html: str, meta: z.record(z.unknown()), clicks: num.int(),
|
|
120
120
|
})) });
|
|
121
121
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { Link } from '../schema';
|
|
3
|
-
import { LinkKind } from '../schema';
|
|
3
|
+
import { LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { displayAnswers } from '../forms/service';
|
|
6
6
|
import type { EditorContext } from './http';
|
|
@@ -56,7 +56,7 @@ export async function getLinksFor(c: EditorContext) {
|
|
|
56
56
|
// next save gets a 409), never newer than the blocks it is sent with
|
|
57
57
|
const revision = await c.repo.linksRevision();
|
|
58
58
|
const [settings, links] = await Promise.all([c.repo.getSettings(), c.repo.listLinks()]);
|
|
59
|
-
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, canEditHtml: isAdmin(c.who) && await principalCan(c.repo, c.who, 'site.links'), items: links.map(linkView) };
|
|
59
|
+
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, avatarShape: settings.linksAvatarShape, avatarFit: settings.linksAvatarFit, avatarScale: settings.linksAvatarScale, verified: settings.linksVerified, canEditHtml: isAdmin(c.who) && await principalCan(c.repo, c.who, 'site.links'), items: links.map(linkView) };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const Item = z.object({
|
|
@@ -71,6 +71,8 @@ export const LinksPut = z.object({
|
|
|
71
71
|
revision: z.number().int().min(0),
|
|
72
72
|
name: z.string().max(120).optional(), bio: z.string().max(500).optional(),
|
|
73
73
|
avatar: z.string().max(1000).refine((v) => !v || /^(https?:\/\/|\/)/.test(v), 'Use an http(s) or site path image.').optional(),
|
|
74
|
+
avatarShape: LinksAvatarShape.optional(), avatarFit: LinksAvatarFit.optional(), avatarScale: LinksAvatarScale.optional(),
|
|
75
|
+
verified: z.boolean().optional(),
|
|
74
76
|
// the whole page in display order; blocks left out are removed
|
|
75
77
|
items: z.array(Item).max(200),
|
|
76
78
|
}).strict();
|
|
@@ -107,6 +109,6 @@ export async function putLinksFor(c: EditorContext, body: z.infer<typeof LinksPu
|
|
|
107
109
|
await c.repo.replaceLinks(body.revision, body.items.map((i) => {
|
|
108
110
|
const old = i.id ? byId.get(i.id) : undefined;
|
|
109
111
|
return { kind: i.kind, label: i.label, url: i.url, icon: i.icon, html: i.html, meta: i.meta, keep: old && old.kind === i.kind ? old.id : undefined };
|
|
110
|
-
}), { linksName: body.name, linksBio: body.bio, linksAvatar: body.avatar });
|
|
112
|
+
}), { linksName: body.name, linksBio: body.bio, linksAvatar: body.avatar, linksVerified: body.verified, linksAvatarShape: body.avatarShape, linksAvatarFit: body.avatarFit, linksAvatarScale: body.avatarScale });
|
|
111
113
|
return getLinksFor(c);
|
|
112
114
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Settings } from './schema';
|
|
2
|
+
|
|
3
|
+
type AvatarSettings = Pick<Settings, 'linksAvatarShape' | 'linksAvatarFit' | 'linksAvatarScale'>;
|
|
4
|
+
|
|
5
|
+
export function linksAvatarStyles(settings: AvatarSettings) {
|
|
6
|
+
return {
|
|
7
|
+
frame: { borderRadius: settings.linksAvatarShape === 'square' ? '0' : '50%' },
|
|
8
|
+
image: { objectFit: settings.linksAvatarFit, transform: `scale(${settings.linksAvatarScale / 100})` },
|
|
9
|
+
};
|
|
10
|
+
}
|
package/src/lib/mobile/links.ts
CHANGED
|
@@ -62,6 +62,10 @@ export async function getLinks(repo: ContentRepository, url: URL) {
|
|
|
62
62
|
name: settings.linksName || settings.siteTitle,
|
|
63
63
|
bio: settings.linksBio || settings.tagline,
|
|
64
64
|
avatar: safeUrl(settings.linksAvatar, origin),
|
|
65
|
+
avatarShape: settings.linksAvatarShape,
|
|
66
|
+
avatarFit: settings.linksAvatarFit,
|
|
67
|
+
avatarScale: settings.linksAvatarScale,
|
|
68
|
+
verified: settings.linksVerified,
|
|
65
69
|
url: page,
|
|
66
70
|
socials: nav.filter((n) => n.area === 'social').map((n) => ({ label: n.label, url: safeUrl(n.url, origin, true), iconPath: socialIconPath(n.url) })).filter((n) => n.url),
|
|
67
71
|
items: links.filter(shown).map((l) => item(l, origin, page)).filter((i) => !('url' in i) || i.url),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { CalEvent, Booking, Calendar, BookingQuestion } from '../schema';
|
|
2
|
+
import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale } from '../schema';
|
|
3
3
|
|
|
4
4
|
const str = z.string(), bool = z.boolean(), strings = z.array(str);
|
|
5
5
|
const date = str.datetime(), locale = str.nullable();
|
|
@@ -45,7 +45,7 @@ export const SearchResults = z.object({ locale, query: str,
|
|
|
45
45
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
|
46
46
|
const ButtonStyle = { color: str.nullable(), textColor: str.nullable() };
|
|
47
47
|
export const Links = z.object({
|
|
48
|
-
name: str, bio: str, avatar: str, url: str,
|
|
48
|
+
name: str, bio: str, avatar: str, avatarShape: LinksAvatarShape, avatarFit: LinksAvatarFit, avatarScale: LinksAvatarScale, verified: bool, url: str,
|
|
49
49
|
socials: z.array(z.object({ label: str, url: str, iconPath: str.nullable() })),
|
|
50
50
|
items: z.array(z.discriminatedUnion('kind', [
|
|
51
51
|
z.object({ id: str, kind: z.literal('link'), label: str, url: str, destination: str, icon: Icon, ...ButtonStyle }),
|
|
@@ -7,6 +7,7 @@ import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
|
|
|
7
7
|
import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
|
|
8
8
|
import { PLACES_MIGRATION } from '../maps/migration';
|
|
9
9
|
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
10
|
+
import { SYNC_MIGRATION } from '../sync/migration';
|
|
10
11
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
11
12
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
12
13
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -632,6 +633,31 @@ export const MIGRATIONS: Migration[] = [
|
|
|
632
633
|
`INSERT INTO backup_state (id) VALUES (1)`,
|
|
633
634
|
] },
|
|
634
635
|
ROLES_MIGRATION,
|
|
636
|
+
SYNC_MIGRATION,
|
|
637
|
+
{ version: 53, statements: [
|
|
638
|
+
`ALTER TABLE settings ADD COLUMN links_verified INTEGER NOT NULL DEFAULT 0`,
|
|
639
|
+
`DROP TRIGGER links_revision_profile`,
|
|
640
|
+
`CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar, links_verified ON settings
|
|
641
|
+
WHEN OLD.links_name IS NOT NEW.links_name OR OLD.links_bio IS NOT NEW.links_bio OR OLD.links_avatar IS NOT NEW.links_avatar OR OLD.links_verified IS NOT NEW.links_verified
|
|
642
|
+
BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
643
|
+
`DROP TRIGGER content_version_settings_update`,
|
|
644
|
+
...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified']),
|
|
645
|
+
] },
|
|
646
|
+
{ version: 54, statements: [
|
|
647
|
+
`ALTER TABLE settings ADD COLUMN links_avatar_shape TEXT NOT NULL DEFAULT 'circle'`,
|
|
648
|
+
`ALTER TABLE settings ADD COLUMN links_avatar_fit TEXT NOT NULL DEFAULT 'cover'`,
|
|
649
|
+
`ALTER TABLE settings ADD COLUMN links_avatar_scale INTEGER NOT NULL DEFAULT 100`,
|
|
650
|
+
`DROP TRIGGER links_revision_profile`,
|
|
651
|
+
`CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar, links_verified, links_avatar_shape, links_avatar_fit, links_avatar_scale ON settings
|
|
652
|
+
WHEN OLD.links_name IS NOT NEW.links_name OR OLD.links_bio IS NOT NEW.links_bio OR OLD.links_avatar IS NOT NEW.links_avatar OR OLD.links_verified IS NOT NEW.links_verified
|
|
653
|
+
OR OLD.links_avatar_shape IS NOT NEW.links_avatar_shape OR OLD.links_avatar_fit IS NOT NEW.links_avatar_fit OR OLD.links_avatar_scale IS NOT NEW.links_avatar_scale
|
|
654
|
+
BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
655
|
+
`DROP TRIGGER content_version_settings_update`,
|
|
656
|
+
...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale']),
|
|
657
|
+
] },
|
|
635
658
|
];
|
|
636
659
|
|
|
660
|
+
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
661
|
+
export const CURRENT_CONTENT_VERSION_COLUMNS = { ...CONTENT_VERSION_COLUMNS, settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale'] };
|
|
662
|
+
|
|
637
663
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AUTH } from '../auth/config';
|
|
2
|
+
import { SyncStore } from '../sync/store';
|
|
2
3
|
import { AuthStore } from '../auth/store';
|
|
3
4
|
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
4
5
|
import { GuestStore } from '../mobile/guest-store';
|
|
@@ -260,6 +261,7 @@ function safeSnippet(raw: unknown): string {
|
|
|
260
261
|
}
|
|
261
262
|
|
|
262
263
|
export class SqlRepository implements ContentRepository {
|
|
264
|
+
readonly sync: SyncStore;
|
|
263
265
|
private driver!: SqlDriver;
|
|
264
266
|
private ready: Promise<void>;
|
|
265
267
|
readonly auth: AuthStore;
|
|
@@ -274,6 +276,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
274
276
|
|
|
275
277
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
276
278
|
this.ready = this.init(driver);
|
|
279
|
+
this.sync = new SyncStore(async () => { await this.ready; return this.driver; });
|
|
277
280
|
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
278
281
|
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
279
282
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
@@ -1990,7 +1993,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1990
1993
|
const r = await this.first('SELECT * FROM settings WHERE id = 1');
|
|
1991
1994
|
return Settings.parse({
|
|
1992
1995
|
siteTitle: r?.site_title ?? 'My Site', tagline: r?.tagline ?? '', footerText: r?.footer_text ?? '',
|
|
1993
|
-
linksName: r?.links_name ?? '', linksBio: r?.links_bio ?? '', linksAvatar: r?.links_avatar ?? '',
|
|
1996
|
+
linksName: r?.links_name ?? '', linksBio: r?.links_bio ?? '', linksAvatar: r?.links_avatar ?? '', linksVerified: Boolean(r?.links_verified),
|
|
1997
|
+
linksAvatarShape: r?.links_avatar_shape ?? 'circle', linksAvatarFit: r?.links_avatar_fit ?? 'cover', linksAvatarScale: Number(r?.links_avatar_scale ?? 100),
|
|
1994
1998
|
activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
|
|
1995
1999
|
sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
|
|
1996
2000
|
siteLogo: r?.site_logo ?? '',
|
|
@@ -2032,7 +2036,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
2032
2036
|
}
|
|
2033
2037
|
private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
|
|
2034
2038
|
const columns: Record<string, string> = {
|
|
2035
|
-
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
2039
|
+
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar', linksVerified:'links_verified',
|
|
2040
|
+
linksAvatarShape:'links_avatar_shape', linksAvatarFit:'links_avatar_fit', linksAvatarScale:'links_avatar_scale',
|
|
2036
2041
|
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
2037
2042
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
2038
2043
|
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring',
|
|
@@ -2042,7 +2047,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
2042
2047
|
if (!entries.length) return;
|
|
2043
2048
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
|
|
2044
2049
|
return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
|
|
2045
|
-
args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
|
|
2050
|
+
args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : typeof v === 'boolean' ? Number(v) : v) };
|
|
2046
2051
|
}
|
|
2047
2052
|
private async designWrite(expected: number, actor: string, action: string, statements: SqlStatement[]) {
|
|
2048
2053
|
try {
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthStore } from '../auth/store';
|
|
2
|
+
import type { SyncStore } from '../sync/store';
|
|
2
3
|
import type { FormStore } from '../forms/store';
|
|
3
4
|
import type { BackupStore } from '../backup/store';
|
|
4
5
|
import type { GuestStore } from '../mobile/guest-store';
|
|
@@ -53,6 +54,7 @@ export type EventUpdate = Partial<
|
|
|
53
54
|
// The single contract both backends implement. The app only ever imports
|
|
54
55
|
// this — never a concrete adapter.
|
|
55
56
|
export interface ContentRepository {
|
|
57
|
+
readonly sync: SyncStore;
|
|
56
58
|
readonly auth: AuthStore;
|
|
57
59
|
readonly email: EmailStore;
|
|
58
60
|
readonly forms: FormStore;
|
|
@@ -241,7 +243,7 @@ export interface ContentRepository {
|
|
|
241
243
|
* in place, other items are added, everything else is removed; `profile`
|
|
242
244
|
* (links name/bio/avatar) saves in the same batch.
|
|
243
245
|
*/
|
|
244
|
-
replaceLinks(expectedRevision: number, items: (Pick<Link, 'kind' | 'label' | 'url' | 'icon' | 'html' | 'meta'> & { keep?: string })[], profile?: Partial<Pick<Settings, 'linksName' | 'linksBio' | 'linksAvatar'>>): Promise<void>;
|
|
246
|
+
replaceLinks(expectedRevision: number, items: (Pick<Link, 'kind' | 'label' | 'url' | 'icon' | 'html' | 'meta'> & { keep?: string })[], profile?: Partial<Pick<Settings, 'linksName' | 'linksBio' | 'linksAvatar' | 'linksVerified' | 'linksAvatarShape' | 'linksAvatarFit' | 'linksAvatarScale'>>): Promise<void>;
|
|
245
247
|
trackClick(id: string): Promise<string | null>; // returns url to redirect to
|
|
246
248
|
|
|
247
249
|
// tags — registry of tag entities; posts store tag names, this table adds
|
package/src/lib/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeEventImageUrl } from './calendar/images';
|
|
1
2
|
import { LiveConfig } from './live/types';
|
|
2
3
|
import { PushSettings } from './push/types';
|
|
3
4
|
import { SectionPages } from '../theme-sdk/contract.mjs';
|
|
@@ -513,8 +514,12 @@ export const ApprovalRules = z.object({
|
|
|
513
514
|
});
|
|
514
515
|
export type ApprovalRules = z.infer<typeof ApprovalRules>;
|
|
515
516
|
|
|
517
|
+
export const EventImageUrl = z.string().trim().max(2000).refine((value) => !value || Boolean(safeEventImageUrl(value)), 'Choose an image or use an http(s) URL or site-relative image path.');
|
|
518
|
+
|
|
516
519
|
// Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
|
|
517
520
|
export const CalendarSettings = z.object({
|
|
521
|
+
// Display-only fallback: event covers remain empty until an image is explicitly chosen.
|
|
522
|
+
eventFallbackImage: EventImageUrl.default(''),
|
|
518
523
|
// first column of every month grid: 0 = Sunday, 1 = Monday
|
|
519
524
|
weekStart: z.union([z.literal(0), z.literal(1)]).default(0),
|
|
520
525
|
// calendar new events land in (quick add, New event); '' = none
|
|
@@ -637,6 +642,7 @@ export const API_SCOPES = [
|
|
|
637
642
|
'bookings:read', 'bookings:write',
|
|
638
643
|
'resources:read', 'resources:write',
|
|
639
644
|
'design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write',
|
|
645
|
+
'sync:read', 'sync:write', 'sync:publish',
|
|
640
646
|
] as const;
|
|
641
647
|
export const ApiScope = z.enum(API_SCOPES);
|
|
642
648
|
export type ApiScope = z.infer<typeof ApiScope>;
|
|
@@ -700,6 +706,10 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
700
706
|
return p;
|
|
701
707
|
}
|
|
702
708
|
|
|
709
|
+
export const LinksAvatarShape = z.enum(['circle', 'square']);
|
|
710
|
+
export const LinksAvatarFit = z.enum(['cover', 'contain']);
|
|
711
|
+
export const LinksAvatarScale = z.number().int().min(25).max(200);
|
|
712
|
+
|
|
703
713
|
export const Settings = z.object({
|
|
704
714
|
live: LiveConfig.default({}),
|
|
705
715
|
calendar: CalendarSettings.default({}),
|
|
@@ -715,6 +725,10 @@ export const Settings = z.object({
|
|
|
715
725
|
linksName: z.string().default(''),
|
|
716
726
|
linksBio: z.string().default(''),
|
|
717
727
|
linksAvatar: z.string().default(''),
|
|
728
|
+
linksAvatarShape: LinksAvatarShape.default('circle'),
|
|
729
|
+
linksAvatarFit: LinksAvatarFit.default('cover'),
|
|
730
|
+
linksAvatarScale: LinksAvatarScale.default(100),
|
|
731
|
+
linksVerified: z.boolean().default(false),
|
|
718
732
|
// theme engine: which src/themes/ folder is active, plus sparse token
|
|
719
733
|
// overrides from the Design screen layered on top of the theme's defaults
|
|
720
734
|
activeTheme: z.string().default(''),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ApiError } from "../api";
|
|
2
|
+
import { hashAgentKey } from "../ai-config";
|
|
3
|
+
import type { ContentRepository } from "../repo/types";
|
|
4
|
+
export const SYNC_SCOPES = ["sync:read", "sync:write", "sync:publish"] as const;
|
|
5
|
+
export async function syncIdentity(
|
|
6
|
+
request: Request,
|
|
7
|
+
repo: ContentRepository,
|
|
8
|
+
required: readonly string[],
|
|
9
|
+
) {
|
|
10
|
+
const origin = request.headers.get("origin");
|
|
11
|
+
if (
|
|
12
|
+
request.headers.get("sec-fetch-site") === "cross-site" ||
|
|
13
|
+
(origin && origin !== new URL(request.url).origin)
|
|
14
|
+
)
|
|
15
|
+
throw new ApiError(403, "Cross-origin sync requests are not allowed.");
|
|
16
|
+
const header = request.headers.get("authorization") ?? "";
|
|
17
|
+
if (!header.startsWith("Bearer "))
|
|
18
|
+
throw new ApiError(401, "Use a scoped sync key.");
|
|
19
|
+
const key = await repo.findApiKeyByHash(hashAgentKey(header.slice(7)));
|
|
20
|
+
if (!key) throw new ApiError(401, "Invalid sync key.");
|
|
21
|
+
if (required.some((s) => !key.scopes.includes(s as any)))
|
|
22
|
+
throw new ApiError(403, `Required scopes: ${required.join(", ")}`);
|
|
23
|
+
return { actor: `key:${key.id}`, scopes: key.scopes };
|
|
24
|
+
}
|