@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/main.mjs +10 -3
  4. package/cli/migrations.generated.mjs +71 -1
  5. package/cli/remote.mjs +1 -1
  6. package/cli/sync.mjs +876 -0
  7. package/docs/environment-sync.md +302 -0
  8. package/integration.mjs +2 -0
  9. package/package.json +1 -1
  10. package/section-library/upcoming-events/UpcomingEvents.astro +26 -17
  11. package/section-library/upcoming-events/section.json +41 -0
  12. package/src/actions/index.ts +5 -1
  13. package/src/components/CalendarFallbackImage.tsx +18 -0
  14. package/src/components/DesignAccess.tsx +3 -0
  15. package/src/components/EventPage.astro +4 -2
  16. package/src/components/LinksManager.tsx +86 -55
  17. package/src/components/ThemeSectionsEditor.tsx +13 -1
  18. package/src/layouts/Site.astro +1 -0
  19. package/src/lib/calendar/cards.ts +4 -2
  20. package/src/lib/calendar/images.ts +17 -0
  21. package/src/lib/editor/schemas.ts +2 -2
  22. package/src/lib/editor/site-data.ts +5 -3
  23. package/src/lib/links-avatar.ts +10 -0
  24. package/src/lib/mobile/links.ts +4 -0
  25. package/src/lib/mobile/schemas.ts +2 -2
  26. package/src/lib/repo/migrations.ts +26 -0
  27. package/src/lib/repo/sql.ts +8 -3
  28. package/src/lib/repo/types.ts +3 -1
  29. package/src/lib/schema.ts +14 -0
  30. package/src/lib/sync/auth.ts +24 -0
  31. package/src/lib/sync/catalog.ts +250 -0
  32. package/src/lib/sync/migration.ts +51 -0
  33. package/src/lib/sync/protocol.mjs +125 -0
  34. package/src/lib/sync/service.ts +994 -0
  35. package/src/lib/sync/store.ts +349 -0
  36. package/src/pages/admin/calendar/settings.astro +21 -2
  37. package/src/pages/admin/integrations.astro +2 -0
  38. package/src/pages/admin/links.astro +1 -1
  39. package/src/pages/api/agent/docs.ts +2 -0
  40. package/src/pages/api/design/keys.ts +7 -3
  41. package/src/pages/api/sync/index.ts +114 -0
  42. package/src/pages/api/sync/media.ts +90 -0
  43. package/src/pages/links.astro +13 -2
  44. package/src/styles/admin.css +27 -36
  45. package/src/styles/page-transitions.css +27 -0
  46. package/src/styles/site.css +3 -1
  47. package/src/styles/theme-editor.css +47 -12
  48. 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 changes" commits them all at once — the live
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
- <div className="lm-profile">
269
- <div className="lm-avatarwrap">
270
- <button
271
- type="button"
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-remove"
287
- title="Remove photo"
288
- onClick={() => setProfile((p) => ({ ...p, linksAvatar: '' }))}
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
- </div>
294
- <div className="lm-profile-fields">
295
- <input
296
- type="text"
297
- className="lm-profile-name"
298
- placeholder="Your name"
299
- aria-label="Display name"
300
- value={profile.linksName}
301
- onChange={(e) => editProfile({ linksName: e.target.value })}
302
- />
303
- <textarea
304
- className="lm-profile-bio"
305
- rows={2}
306
- placeholder="Write a short bio…"
307
- aria-label="Bio"
308
- value={profile.linksBio}
309
- onChange={(e) => {
310
- e.target.style.height = 'auto';
311
- e.target.style.height = `${e.target.scrollHeight}px`;
312
- editProfile({ linksBio: e.target.value });
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
- </div>
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="Links page preview" />
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
- {dirty && (
439
- <div className="lm-savebar" role="status">
440
- <span>{saveState === 'error' ? 'Save failed — try again' : 'Unsaved changes'}</span>
441
- <button type="button" className="btn sm" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
442
- <button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={saveState === 'saving'}>
443
- {saveState === 'saving' ? 'Saving…' : 'Save changes'}
444
- </button>
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
  </>
@@ -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
- ? e.cover
41
- ? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(e.cover)}" alt="${esc(e.coverAlt)}" loading="lazy" /></a>`
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
+ }
@@ -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;
@@ -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 {
@@ -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
+ }