@acms-subir/core 0.3.17 → 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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.18
4
+
5
+ ### Patch Changes
6
+
7
+ - 003a885: Improve links profile customization with photo upload, name and handle fields, an optional verification badge, circle or square avatars, and image fit and scale controls. Clarify that saving links publishes their changes.
8
+
9
+ Add configurable calendar fallback images and upcoming-event image, layout, location, and summary controls, including agent discovery and editing support. Extend client types for the profile settings.
10
+
11
+ Add native public-page view transitions with reduced-motion support. Separate admin page sections with alternating surfaces and clearer spacing, and move section deletion into a distinct action area.
12
+
3
13
  ## 0.3.17
4
14
 
5
15
  ### Patch Changes
@@ -1031,8 +1031,30 @@ var MIGRATIONS = [
1031
1031
  `INSERT INTO backup_state (id) VALUES (1)`
1032
1032
  ] },
1033
1033
  ROLES_MIGRATION,
1034
- SYNC_MIGRATION
1034
+ SYNC_MIGRATION,
1035
+ { version: 53, statements: [
1036
+ `ALTER TABLE settings ADD COLUMN links_verified INTEGER NOT NULL DEFAULT 0`,
1037
+ `DROP TRIGGER links_revision_profile`,
1038
+ `CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar, links_verified ON settings
1039
+ 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
1040
+ BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
1041
+ `DROP TRIGGER content_version_settings_update`,
1042
+ ...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified"])
1043
+ ] },
1044
+ { version: 54, statements: [
1045
+ `ALTER TABLE settings ADD COLUMN links_avatar_shape TEXT NOT NULL DEFAULT 'circle'`,
1046
+ `ALTER TABLE settings ADD COLUMN links_avatar_fit TEXT NOT NULL DEFAULT 'cover'`,
1047
+ `ALTER TABLE settings ADD COLUMN links_avatar_scale INTEGER NOT NULL DEFAULT 100`,
1048
+ `DROP TRIGGER links_revision_profile`,
1049
+ `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
1050
+ 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
1051
+ 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
1052
+ BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
1053
+ `DROP TRIGGER content_version_settings_update`,
1054
+ ...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"])
1055
+ ] }
1035
1056
  ];
1057
+ var CURRENT_CONTENT_VERSION_COLUMNS = { ...CONTENT_VERSION_COLUMNS, settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"] };
1036
1058
  var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
1037
1059
 
1038
1060
  // src/lib/repo/migrate-runner.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,9 +1,12 @@
1
1
  ---
2
+ import { eventImage } from '@acms-subir/core/theme';
2
3
  import type { SectionProps, EventItem, LinkValue } from '@acms-subir/core/theme';
3
- interface Data { heading: string; intro: string; events: EventItem[]; more: LinkValue; empty: string }
4
- const { data, variant } = Astro.props as SectionProps<Data>;
4
+ interface Data { heading: string; intro: string; events: EventItem[]; more: LinkValue; empty: string; fallbackImage: string; showImages: boolean; imageRatio: string; imageFit: string; showLocation: boolean; showSummary: boolean }
5
+ const { data, variant, settings } = Astro.props as SectionProps<Data>;
6
+ const ratio = ({ landscape: '16 / 9', square: '1', portrait: '3 / 4' } as Record<string, string>)[data.imageRatio] || '16 / 9';
7
+ const fit = data.imageFit === 'contain' ? 'contain' : 'cover';
5
8
  ---
6
- <div class:list={['upev', `is-${variant}`]}>
9
+ <div style={`--event-image-ratio:${ratio};--event-image-fit:${fit}`} class:list={['upev', `is-${variant}`]}>
7
10
  <header class="upev-head">
8
11
  <div>
9
12
  <h2>{data.heading}</h2>
@@ -13,18 +16,22 @@ const { data, variant } = Astro.props as SectionProps<Data>;
13
16
  </header>
14
17
  {data.events.length === 0 ? <p class="upev-empty">{data.empty}</p> : (
15
18
  <ol class="upev-list">
16
- {data.events.map((e) => (
17
- <li class:list={['upev-item', { 'is-cancelled': e.cancelled }]}>
18
- {variant === 'cards' && e.image
19
- ? <a class="upev-media" href={e.url} tabindex="-1" aria-hidden="true"><img src={e.image} alt="" loading="lazy" /></a>
20
- : <span class="upev-date" aria-hidden="true"><b>{e.day}</b>{e.month}</span>}
21
- <div class="upev-body">
22
- <span class="upev-when">{e.cancelled ? 'Cancelled · ' : ''}{e.when}</span>
23
- <h3 class="upev-title"><a href={e.url}>{e.title}</a></h3>
24
- {e.location && <span class="upev-loc">{e.location}</span>}
25
- </div>
26
- </li>
27
- ))}
19
+ {data.events.map((e) => {
20
+ const image = eventImage({ cover: e.image, coverAlt: e.imageAlt }, settings, data.fallbackImage);
21
+ return (
22
+ <li class:list={['upev-item', { 'is-cancelled': e.cancelled }]}>
23
+ {data.showImages !== false && image.src
24
+ ? <a class="upev-media" href={e.url} tabindex="-1" aria-hidden="true"><img src={image.src} alt="" loading="lazy" /></a>
25
+ : <span class="upev-date" aria-hidden="true"><b>{e.day}</b>{e.month}</span>}
26
+ <div class="upev-body">
27
+ <span class="upev-when">{e.cancelled ? 'Cancelled · ' : ''}{e.when}</span>
28
+ <h3 class="upev-title"><a href={e.url}>{e.title}</a></h3>
29
+ {data.showSummary && e.summary && <p class="upev-summary">{e.summary}</p>}
30
+ {data.showLocation !== false && e.location && <span class="upev-loc">{e.location}</span>}
31
+ </div>
32
+ </li>
33
+ );
34
+ })}
28
35
  </ol>
29
36
  )}
30
37
  </div>
@@ -40,8 +47,10 @@ const { data, variant } = Astro.props as SectionProps<Data>;
40
47
  .upev-item { display: flex; gap: 1rem; align-items: flex-start; }
41
48
  .is-cards .upev-item { flex-direction: column; padding: 1rem; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); }
42
49
  .is-list .upev-item { padding-bottom: 1rem; border-bottom: 1px solid var(--line); }
43
- .upev-media { display: block; width: 100%; aspect-ratio: 16 / 9; border-radius: var(--r-sm); overflow: hidden; background: var(--soft); }
44
- .upev-media img { width: 100%; height: 100%; object-fit: cover; }
50
+ .upev-media { display: block; width: 100%; aspect-ratio: var(--event-image-ratio); border-radius: var(--r-sm); overflow: hidden; background: var(--soft); }
51
+ .upev-media img { width: 100%; height: 100%; object-fit: var(--event-image-fit); }
52
+ .is-list .upev-media { width: 6rem; flex: 0 0 6rem; }
53
+ .upev-summary { margin: 0.4rem 0 0; color: var(--muted); }
45
54
  .upev-date { flex-shrink: 0; display: grid; place-items: center; width: 3.4rem; padding: 0.45rem 0; border-radius: var(--r-sm); background: var(--ink); color: var(--bg); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.06em; line-height: 1.2; }
46
55
  .upev-date b { font-size: 1.35rem; letter-spacing: 0; }
47
56
  .upev-body { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
@@ -40,6 +40,47 @@
40
40
  "label": "Text when nothing is scheduled",
41
41
  "type": "text",
42
42
  "default": "Nothing scheduled right now. Check back soon."
43
+ },
44
+ "fallbackImage": {
45
+ "label": "Fallback event image",
46
+ "type": "image",
47
+ "default": "",
48
+ "help": "Used only when an event has no image. Leave empty to use the fallback in Calendar settings."
49
+ },
50
+ "showImages": {
51
+ "label": "Show event images",
52
+ "type": "toggle",
53
+ "default": true
54
+ },
55
+ "imageRatio": {
56
+ "label": "Image proportions",
57
+ "type": "select",
58
+ "options": [
59
+ "landscape",
60
+ "square",
61
+ "portrait"
62
+ ],
63
+ "default": "landscape"
64
+ },
65
+ "imageFit": {
66
+ "label": "Image fit",
67
+ "type": "select",
68
+ "options": [
69
+ "cover",
70
+ "contain"
71
+ ],
72
+ "default": "cover",
73
+ "help": "Cover fills the frame; contain shows the whole image."
74
+ },
75
+ "showLocation": {
76
+ "label": "Show event location",
77
+ "type": "toggle",
78
+ "default": true
79
+ },
80
+ "showSummary": {
81
+ "label": "Show event summary",
82
+ "type": "toggle",
83
+ "default": false
43
84
  }
44
85
  },
45
86
  "category": "Events & times"
@@ -10,7 +10,7 @@ import { getMediaStore } from '../lib/media/store';
10
10
  import { validateRequestSession } from '../lib/auth/request';
11
11
  import { accessFor } from '../lib/permissions/access';
12
12
  import { deniedMessage, type Permission } from '../lib/permissions/catalog';
13
- import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
13
+ import { DocTranslations, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
14
14
  import { env } from '../lib/env';
15
15
  import { CONTENT, collectionUrl } from '../lib/content';
16
16
  import { invalidateRedirectCache } from '../middleware';
@@ -489,6 +489,10 @@ export const server = {
489
489
  linksName: z.string().optional(),
490
490
  linksBio: z.string().optional(),
491
491
  linksAvatar: z.string().optional(),
492
+ linksAvatarShape: LinksAvatarShape.optional(),
493
+ linksAvatarFit: LinksAvatarFit.optional(),
494
+ linksAvatarScale: LinksAvatarScale.optional(),
495
+ linksVerified: z.boolean().optional(),
492
496
  activeTheme: z.string().optional(),
493
497
  designOverrides: z.record(z.any()).optional(),
494
498
  siteLogo: z.string().optional(),
@@ -0,0 +1,18 @@
1
+ import { useState } from 'react';
2
+ import { ImagePicker } from './editor/ImagePicker';
3
+
4
+ export default function CalendarFallbackImage({ value, unsplashEnabled }: { value: string; unsplashEnabled: boolean }) {
5
+ const [image, setImage] = useState(value);
6
+ const [open, setOpen] = useState(false);
7
+ return (
8
+ <div>
9
+ <input type="hidden" name="eventFallbackImage" value={image} />
10
+ {image && <img src={image} alt="Selected fallback event image" style={{ display: 'block', width: '100%', maxWidth: 320, aspectRatio: '16 / 9', objectFit: 'cover', borderRadius: 8, marginBottom: 12 }} />}
11
+ <div className="row-actions">
12
+ <button type="button" className="btn" onClick={() => setOpen(true)}>{image ? 'Change fallback image' : 'Choose fallback image'}</button>
13
+ {image && <button type="button" className="btn ghost" onClick={() => setImage('')}>Remove image</button>}
14
+ </div>
15
+ <ImagePicker open={open} unsplashEnabled={unsplashEnabled} onClose={() => setOpen(false)} onSelect={(picked) => { setImage(picked.src); setOpen(false); }} />
16
+ </div>
17
+ );
18
+ }
@@ -9,6 +9,7 @@ import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Oc
9
9
  import { describeRepeat } from '../lib/calendar/rrule';
10
10
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
11
  import { siteLocale } from '../lib/dates';
12
+ import { eventImage } from '../lib/calendar/images';
12
13
  import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
13
14
 
14
15
  interface Props {
@@ -24,6 +25,7 @@ interface Props {
24
25
  rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
25
26
  }
26
27
  const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
28
+ const image = eventImage(e, settings);
27
29
  const tz = eventTimeZone(e, settings);
28
30
  const locale = siteLocale(settings);
29
31
  const f = formatOccurrence(o, tz, locale);
@@ -61,8 +63,8 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
61
63
 
62
64
  <article class={`pc-ev-page${e.cancelled ? ' is-cancelled' : ''}`}>
63
65
  {preview && <p class="pc-ev-preview">Preview — only you can see this {e.status === 'published' ? 'version' : 'draft'}.</p>}
64
- {e.cover && (
65
- <figure class="pc-ev-hero"><img src={e.cover} alt={e.coverAlt} /></figure>
66
+ {image.src && (
67
+ <figure class="pc-ev-hero"><img src={image.src} alt={image.alt} /></figure>
66
68
  )}
67
69
  <div class="pc-ev-layout">
68
70
  <div class="pc-ev-main">
@@ -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 }),
@@ -634,6 +634,30 @@ export const MIGRATIONS: Migration[] = [
634
634
  ] },
635
635
  ROLES_MIGRATION,
636
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
+ ] },
637
658
  ];
638
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
+
639
663
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1993,7 +1993,8 @@ export class SqlRepository implements ContentRepository {
1993
1993
  const r = await this.first('SELECT * FROM settings WHERE id = 1');
1994
1994
  return Settings.parse({
1995
1995
  siteTitle: r?.site_title ?? 'My Site', tagline: r?.tagline ?? '', footerText: r?.footer_text ?? '',
1996
- 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),
1997
1998
  activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
1998
1999
  sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
1999
2000
  siteLogo: r?.site_logo ?? '',
@@ -2035,7 +2036,8 @@ export class SqlRepository implements ContentRepository {
2035
2036
  }
2036
2037
  private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
2037
2038
  const columns: Record<string, string> = {
2038
- 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',
2039
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',
2040
2042
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
2041
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',
@@ -2045,7 +2047,7 @@ export class SqlRepository implements ContentRepository {
2045
2047
  if (!entries.length) return;
2046
2048
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
2047
2049
  return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
2048
- 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) };
2049
2051
  }
2050
2052
  private async designWrite(expected: number, actor: string, action: string, statements: SqlStatement[]) {
2051
2053
  try {
@@ -243,7 +243,7 @@ export interface ContentRepository {
243
243
  * in place, other items are added, everything else is removed; `profile`
244
244
  * (links name/bio/avatar) saves in the same batch.
245
245
  */
246
- 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>;
247
247
  trackClick(id: string): Promise<string | null>; // returns url to redirect to
248
248
 
249
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
@@ -701,6 +706,10 @@ export function normalizeRedirectPath(input: string): string {
701
706
  return p;
702
707
  }
703
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
+
704
713
  export const Settings = z.object({
705
714
  live: LiveConfig.default({}),
706
715
  calendar: CalendarSettings.default({}),
@@ -716,6 +725,10 @@ export const Settings = z.object({
716
725
  linksName: z.string().default(''),
717
726
  linksBio: z.string().default(''),
718
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),
719
732
  // theme engine: which src/themes/ folder is active, plus sparse token
720
733
  // overrides from the Design screen layered on top of the theme's defaults
721
734
  activeTheme: z.string().default(''),
@@ -1,8 +1,11 @@
1
1
  ---
2
2
  import Admin from '../../../layouts/Admin.astro';
3
+ import CalendarFallbackImage from '../../../components/CalendarFallbackImage';
4
+ import { env } from '../../../lib/env';
5
+ import '../../../styles/editor.css';
3
6
  import Modal from '../../../components/Modal.astro';
4
7
  import { getRepo } from '../../../lib/repo';
5
- import { ApprovalRules, slugify, type ApprovalMode, type CalendarVisibility } from '../../../lib/schema';
8
+ import { EventImageUrl, ApprovalRules, slugify, type ApprovalMode, type CalendarVisibility } from '../../../lib/schema';
6
9
  import { CALENDAR, bookingsEnabled, calendarFeedUrl, calendarPageUrl } from '../../../lib/calendar/config';
7
10
  import { safeColor } from '../../../lib/calendar/events';
8
11
  import { normalizeCalendarSrc } from '../../../lib/calendar/src';
@@ -54,6 +57,12 @@ if (Astro.request.method === 'POST') {
54
57
  const color = safeColor(String(form.get('color') || '#4f46e5'));
55
58
  const description = String(form.get('description') || '').slice(0, 1000);
56
59
 
60
+ if (op === 'event-images') {
61
+ const image = EventImageUrl.safeParse(form.get('eventFallbackImage'));
62
+ if (!image.success) return back(image.error.issues[0].message, '#event-images');
63
+ await repo.updateSettings({ calendar: { ...settings.calendar, eventFallbackImage: image.data } });
64
+ return back('Event fallback image saved.', '#event-images');
65
+ }
57
66
  if (op === 'defaults') {
58
67
  const defaultCalendarId = String(form.get('defaultCalendarId') || '');
59
68
  const ok = !defaultCalendarId || (await repo.getCalendar(defaultCalendarId))?.kind === 'native';
@@ -188,7 +197,7 @@ const I = {
188
197
  pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
189
198
  };
190
199
  const sections = [
191
- ['calendars', 'Calendars'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
200
+ ['calendars', 'Calendars'], ['event-images', 'Event images'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
192
201
  ...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
193
202
  ] as const;
194
203
  ---
@@ -242,6 +251,16 @@ const sections = [
242
251
  ))}
243
252
  </section>
244
253
 
254
+ <section class="cs-sect" id="event-images">
255
+ <header><div><h2>Event images</h2><p>Choose a fallback for events without their own image. Used on event cards, event pages, and Upcoming events sections.</p></div></header>
256
+ <form method="post">
257
+ <input type="hidden" name="op" value="event-images" />
258
+ <CalendarFallbackImage client:load value={settings.calendar.eventFallbackImage} unsplashEnabled={Boolean(env('UNSPLASH_ACCESS_KEY'))} />
259
+ <p class="hint">An event’s own image takes priority. Upcoming events sections can choose a different fallback in Design → Pages. Removing the fallback restores date tiles on cards.</p>
260
+ <button class="btn primary" type="submit">Save event image</button>
261
+ </form>
262
+ </section>
263
+
245
264
  <!-- Places -->
246
265
  <section class="cs-sect" id="places">
247
266
  <header>
@@ -13,7 +13,7 @@ const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
13
13
 
14
14
  <Admin title="Links page" section="links">
15
15
  <div class="page-head">
16
- <div><h1>Links page</h1><p class="sub">Your link-in-bio page at /links</p></div>
16
+ <div><h1>Links page</h1><p class="sub">Your link-in-bio page at /links. Save &amp; publish makes changes live immediately.</p></div>
17
17
  <a class="btn sm" href="/links" target="_blank" rel="noopener">View links page ↗</a>
18
18
  </div>
19
19
 
@@ -27,6 +27,8 @@ Connect a Streamable HTTP MCP client to ${url.origin}/api/mcp with the same Auth
27
27
 
28
28
  Themes define editable sections (hero, service times, upcoming events…) with typed fields. list_section_types returns the active theme's section types and field schemas; get_page_layout reads a page's layout; set_page_layout replaces it; update_section_data changes one section. The two write tools appear only when the owner turns on "Let the agent edit page sections". Field values: text/textarea/richtext/image/url/select/date (YYYY-MM-DD)/time (HH:MM) are strings, number is a number, toggle is a boolean, link is {"label","href"} (optionally "ref" like "page:<id>"), list is an array of row objects keyed by its sub-fields, source is {"calendar"|"collection"|"tag"|"resource", "limit"} and shows live content. All section writes stage into the design draft (review at the returned reviewUrl). update_section_data with publish:true publishes that one section when publishing is enabled.
29
29
 
30
+ The library upcoming-events section supports cards/list variants and editable heading, intro, events (calendar and limit), more, empty, fallbackImage, showImages, imageRatio (landscape/square/portrait), imageFit (cover/contain), showLocation, and showSummary. Discover the active theme's schema first: copied or custom sections may expose different fields. Event images take priority over the section fallback, then the fallback chosen by the owner in Calendar settings → Event images. For a section override, call update_section_data with fallbackImage and the desired display fields; it follows the normal draft and publishing permissions.
31
+
30
32
  Updates replace only supplied fields; content replaces the body. Pages support title/content/seoTitle/seoDesc. Posts also support excerpt/tags. Create accepts slug and posts accept collection. Content length is limited to 100,000 characters; request size is limited to 300,000 bytes. List pagination defaults to 25 and allows up to 100.
31
33
 
32
34
  ## Tool schemas (publish_content requires permission)
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import LiveCard from '../components/LiveCard.astro';
3
+ import { linksAvatarStyles } from '../lib/links-avatar';
3
4
  import Site from '../layouts/Site.astro';
4
5
  import { getRepo } from '../lib/repo';
5
6
  import { cleanEventName } from '../lib/analytics';
@@ -17,6 +18,7 @@ const socials = navItems
17
18
  .map((n) => ({ ...n, path: socialIconPath(n.url) }));
18
19
 
19
20
  // links page has its own identity; falls back to the site's until it's set
21
+ const avatarStyles = linksAvatarStyles(settings);
20
22
  const profileName = settings.linksName || settings.siteTitle;
21
23
  const profileBio = settings.linksBio || settings.tagline;
22
24
 
@@ -94,8 +96,17 @@ const visible = links.filter((l) => {
94
96
 
95
97
  <Site title="Links" bare>
96
98
  <div class="links-page">
97
- {settings.linksAvatar && <img class="links-avatar" src={settings.linksAvatar} alt={profileName} />}
98
- <h1>{profileName}</h1>
99
+ {settings.linksAvatar && (
100
+ <div class="links-avatar" style={avatarStyles.frame}>
101
+ <img src={settings.linksAvatar} alt={profileName} style={avatarStyles.image} />
102
+ </div>
103
+ )}
104
+ <h1>{profileName}{settings.linksVerified && (
105
+ <svg class="links-verified" viewBox="0 0 24 24" role="img" aria-label="Verified">
106
+ <path fill="currentColor" d="m12 1 3 2.2 3.7.1 1.2 3.5L22 10l-1 3.6.3 3.7-3.4 1.5-2.4 2.8L12 20.5l-3.5 1.1-2.4-2.8-3.4-1.5.3-3.7L2 10l2.1-3.2 1.2-3.5 3.7-.1Z" />
107
+ <path d="m7.5 11.8 3 3 6-6" fill="none" stroke="var(--bg)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
108
+ </svg>
109
+ )}</h1>
99
110
  {profileBio && <p class="tagline">{profileBio}</p>}
100
111
  {socials.length > 0 && (
101
112
  <nav class="social-nav" aria-label="Social">
@@ -504,6 +504,7 @@ input.bare.mono { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12
504
504
  display: flex; align-items: center; gap: 8px; padding: 7px 8px 7px 16px;
505
505
  background: var(--text); color: var(--bg); border-radius: var(--r-lg); box-shadow: var(--shadow-pop); white-space: nowrap;
506
506
  }
507
+ @media (max-width: 420px) { .lm-savebar { width: calc(100% - 24px); flex-wrap: wrap; justify-content: flex-end; } .lm-savebar > span { flex: 1 0 100%; } }
507
508
  .lm-savebar span { margin-right: 8px; font-weight: 600; font-size: 13px; }
508
509
  .lm-savebar .btn:not(.primary) { background: transparent; color: var(--bg); border-color: color-mix(in srgb, var(--bg) 30%, transparent); box-shadow: none; }
509
510
  .lm-url { flex: 1; min-width: 0; background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--r-sm); padding: 4px 10px; font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -518,44 +519,34 @@ input.bare.mono { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12
518
519
  color: var(--text-2); background: var(--sel); border-radius: 5px; padding: 2px 7px;
519
520
  }
520
521
 
521
- /* profile card — reads like the links page header, edits in place */
522
- .lm-profile { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; }
523
- .lm-avatarwrap { position: relative; flex: 0 0 64px; }
524
- .lm-avatar {
525
- width: 64px; height: 64px; border-radius: 50%; border: none; padding: 0;
526
- display: grid; place-items: center; font-size: 20px; font-weight: 700; color: var(--text-2);
527
- background: var(--sel);
528
- user-select: none; cursor: pointer; position: relative; overflow: hidden; font-family: inherit;
529
- }
522
+ /* Explicit profile controls stay visible on touch screens, too. */
523
+ .lm-profile-card { padding: 20px; margin-bottom: 20px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--panel); }
524
+ .lm-profile-card h2 { margin: 0 0 18px; font-size: 16px; }
525
+ .lm-profile { display: flex; gap: 20px; align-items: flex-start; }
526
+ .lm-avatarwrap { display: flex; flex-direction: column; align-items: center; gap: 8px; flex: 0 0 110px; }
527
+ .lm-avatar { width: 80px; height: 80px; border-radius: 50%; border: 1px solid var(--line); padding: 0; display: grid; place-items: center; color: var(--text-2); background: var(--sel); cursor: pointer; position: relative; overflow: hidden; }
530
528
  .lm-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
531
- .lm-avatar-hint {
532
- position: absolute; inset: auto 0 0 0; padding: 2px 0 3px;
533
- font-size: 9px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
534
- background: rgb(0 0 0 / 55%); color: #fff; opacity: 0; transition: opacity 0.12s;
535
- }
536
- .lm-avatar:hover .lm-avatar-hint { opacity: 1; }
537
- .lm-avatar-remove {
538
- position: absolute; top: -4px; right: -4px; width: 18px; height: 18px;
539
- border-radius: 50%; border: 1px solid var(--line-strong); background: var(--panel); color: var(--muted);
540
- font-size: 9px; cursor: pointer; display: grid; place-items: center; padding: 0;
541
- opacity: 0; transition: opacity 0.12s;
542
- }
543
- .lm-avatarwrap:hover .lm-avatar-remove { opacity: 1; }
544
- .lm-avatar-remove:hover { color: var(--red); border-color: var(--red); }
529
+ .lm-photo-controls { min-width: 0; margin: 0 0 20px; padding: 0 0 16px; border: 0; border-bottom: 1px solid var(--line); }
530
+ .lm-photo-controls legend { margin-bottom: 10px; padding: 0; font-size: 12px; font-weight: 600; }
531
+ .lm-photo-options { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
532
+ .lm-photo-options label { flex: 1 1 130px; min-width: 0; }
533
+ .lm-photo-controls label { font-size: 12px; }
534
+ .lm-photo-controls select { display: block; width: 100%; margin-top: 5px; }
535
+ .lm-photo-controls output { margin-left: 6px; font-variant-numeric: tabular-nums; font-weight: 600; }
536
+ .lm-photo-controls input[type='range'] { display: block; width: 100%; padding: 0; margin: 8px 0; accent-color: var(--accent); }
537
+ .lm-photo-controls p { margin: 0 0 10px; color: var(--muted); font-size: 12px; line-height: 1.5; }
545
538
  .lm-profile-fields { flex: 1; min-width: 0; }
546
- input.lm-profile-name {
547
- display: block; width: 100%; background: none; border: 1px solid transparent; border-radius: var(--r-md);
548
- color: var(--text); font-family: inherit; font-size: 17px; font-weight: 700; letter-spacing: -0.01em;
549
- padding: 3px 8px; margin-left: -8px; outline: none;
550
- }
551
- .lm-profile-bio {
552
- display: block; width: 100%; background: none; border: 1px solid transparent; border-radius: var(--r-md);
553
- color: var(--muted); font-family: inherit; font-size: 13px; line-height: 1.55;
554
- padding: 3px 8px; margin: 1px 0 0 -8px; outline: none; resize: none; overflow: hidden;
555
- }
556
- .lm-profile-name::placeholder, .lm-profile-bio::placeholder { color: var(--faint); }
557
- input.lm-profile-name:hover, .lm-profile-bio:hover { border-color: var(--line); }
558
- input.lm-profile-name:focus, .lm-profile-bio:focus { border-color: var(--accent); background: var(--panel); }
539
+ .lm-profile-fields > label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; }
540
+ input.lm-profile-name, .lm-profile-bio { display: block; width: 100%; margin: 0 0 14px; padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); color: var(--text); font: inherit; }
541
+ .lm-profile-bio { resize: vertical; min-height: 70px; }
542
+ .lm-profile-fields > .lm-verification { display: flex; align-items: center; gap: 10px; margin: 4px 0 0; cursor: pointer; }
543
+ .lm-verification input { appearance: none; width: 34px; height: 20px; padding: 2px; margin: 0; flex: 0 0 auto; border: 1px solid var(--line-strong); border-radius: 99px; background: var(--line-strong); cursor: pointer; }
544
+ .lm-verification input::before { content: ''; display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--panel); transition: transform 0.15s; }
545
+ .lm-verification input:checked { background: var(--accent); border-color: var(--accent); }
546
+ .lm-verification input:checked::before { transform: translateX(14px); }
547
+ .lm-verification input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
548
+ .lm-verification small { display: block; margin-top: 3px; color: var(--muted); font-weight: 400; }
549
+ @media (max-width: 540px) { .lm-profile { flex-direction: column; } .lm-avatarwrap { flex-basis: auto; } .lm-profile-fields { width: 100%; } }
559
550
 
560
551
  /* add-block picker */
561
552
  .lm-picker-head { display: flex; align-items: center; gap: 8px; }
@@ -0,0 +1,27 @@
1
+ /* Native same-origin document transitions. Both pages opt in through Site.astro;
2
+ the browser keeps normal navigation, history, forms and script lifecycles. */
3
+ @view-transition {
4
+ navigation: auto;
5
+ }
6
+
7
+ /* Keep the browser's built-in crossfade; themes can adjust its pace. */
8
+ @layer base {
9
+ ::view-transition-group(root),
10
+ ::view-transition-old(root),
11
+ ::view-transition-new(root) {
12
+ animation-duration: var(--page-transition-duration, 180ms);
13
+ animation-timing-function: var(--page-transition-easing, ease-out);
14
+ }
15
+ }
16
+
17
+ @media (prefers-reduced-motion: reduce) {
18
+ @view-transition {
19
+ navigation: none;
20
+ }
21
+
22
+ ::view-transition-group(*),
23
+ ::view-transition-old(*),
24
+ ::view-transition-new(*) {
25
+ animation: none !important;
26
+ }
27
+ }
@@ -488,10 +488,12 @@ li::marker { color: var(--muted); }
488
488
  /* ---- links page (/links) ------------------------------------------------------------ */
489
489
  .links-page { max-width: 480px; margin: 0 auto; text-align: center; padding: 48px 0 24px; }
490
490
  .links-page h1 { font-size: 26px; letter-spacing: -0.03em; margin: 0 0 6px; }
491
+ .links-verified { display: inline-block; width: 0.8em; height: 0.8em; margin-left: 0.25em; vertical-align: -0.02em; color: var(--muted); }
491
492
  .links-avatar {
492
- width: 96px; height: 96px; border-radius: 50%; object-fit: cover; display: block; margin: 0 auto 16px;
493
+ width: 96px; height: 96px; border-radius: 50%; overflow: hidden; background: var(--panel); margin: 0 auto 16px;
493
494
  box-shadow: 0 0 0 4px var(--panel), var(--sh-2);
494
495
  }
496
+ .links-avatar img { width: 100%; height: 100%; display: block; object-fit: cover; transform-origin: center; }
495
497
  .links-page .tagline { color: var(--muted); margin: 0 0 18px; white-space: pre-wrap; font-size: 15.5px; }
496
498
  .links-page .social-nav { justify-content: center; margin: 0 0 26px; }
497
499
  .link-btn {
@@ -36,12 +36,25 @@
36
36
  .te-mode .seg > [role='radio'] { min-height: 28px; }
37
37
  .te-mode .seg > [role='radio']:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
38
38
 
39
- /* section list */
40
- .te-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--panel); overflow: hidden; }
39
+ /* section list: each section is its own card, with a gap, alternating surface and
40
+ a stronger frame when open, so it is clear where one section ends */
41
+ .te-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
41
42
  .te-list.is-inactive { opacity: 0.72; }
42
- .te-none { padding: 14px 16px; margin: 0; }
43
- .te-sec + .te-sec { border-top: 1px solid var(--line); }
44
- .te-sec-head { display: flex; align-items: center; gap: 4px; padding: 4px 6px 4px 0; }
43
+ .te-none { padding: 14px 16px; margin: 0; border: 1px dashed var(--line-strong); border-radius: var(--r-lg); }
44
+ .te-sec {
45
+ border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--panel);
46
+ transition: border-color 150ms ease-out, box-shadow 150ms ease-out;
47
+ }
48
+ .te-sec:nth-child(even) { background: var(--panel-2); }
49
+ .te-sec:hover { border-color: var(--line-strong); }
50
+ .te-sec.is-hidden { border-style: dashed; }
51
+ .te-sec.is-open {
52
+ margin: 8px 0; background: var(--panel); border-color: var(--line-strong);
53
+ box-shadow: var(--shadow-card);
54
+ }
55
+ .te-sec.is-open:first-child { margin-top: 0; }
56
+ .te-sec.is-open:last-child { margin-bottom: 0; }
57
+ .te-sec-head { display: flex; align-items: center; gap: 4px; padding: 4px 6px 4px 0; border-radius: calc(var(--r-lg) - 1px); }
45
58
  .te-sec-toggle {
46
59
  flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; min-height: 40px;
47
60
  padding: 6px 8px 6px 12px; border: 0; background: none; font: inherit; color: var(--text); text-align: left;
@@ -51,7 +64,9 @@
51
64
  .te-sec-toggle:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
52
65
  .te-sec-toggle svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--muted); transition: transform 150ms ease-out; }
53
66
  .te-sec.is-open .te-sec-toggle svg { transform: rotate(90deg); }
54
- .te-sec.is-open .te-sec-head { background: var(--sel); }
67
+ .te-sec.is-open .te-sec-head {
68
+ background: var(--sel); border-radius: calc(var(--r-lg) - 1px) calc(var(--r-lg) - 1px) 0 0;
69
+ }
55
70
  .te-sec.is-open .te-sec-name { font-weight: 600; }
56
71
  .te-sec-name { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
57
72
  .te-sec-meta { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -61,10 +76,28 @@
61
76
  .te-sec-actions .icon-btn:disabled { opacity: 0.35; cursor: default; background: none; }
62
77
  .te-sec-actions .icon-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
63
78
  .te svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
64
- .te-sec-body { padding: 14px 16px 12px; border-top: 1px solid var(--line); }
79
+ .te-sec-body { padding: 16px 16px 14px; border-top: 1px solid var(--line-strong); }
65
80
  .te-sec-body textarea { resize: vertical; min-height: 84px; }
66
- .te-sec-foot { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 12px; border-top: 1px solid var(--line); }
67
- .te-sec-foot .te-delete { margin-left: auto; }
81
+ .te-sec-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line); }
82
+ .te-sec-foot:empty { display: none; }
83
+ .te-sec-foot .te-publish-one { margin-left: auto; }
84
+ .te-sec-foot .btn:focus-visible, .te-more > summary:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
85
+ /* destructive action lives in its own tinted strip, apart from everyday tools */
86
+ .te-sec-danger {
87
+ display: flex; align-items: center; gap: 12px; margin-top: 14px; padding: 10px 12px;
88
+ border: 1px solid color-mix(in srgb, var(--red) 28%, transparent); border-radius: var(--r-md);
89
+ background: var(--red-soft);
90
+ }
91
+ .te-sec-danger-text { flex: 1; min-width: 0; margin: 0; display: flex; flex-direction: column; gap: 1px; font-size: 12px; color: var(--text-2); }
92
+ .te-sec-danger-text b { font-size: 12.5px; font-weight: 600; color: var(--red); }
93
+ .te-sec-delete.btn {
94
+ flex-shrink: 0; color: var(--red); background: var(--panel);
95
+ border-color: color-mix(in srgb, var(--red) 45%, transparent);
96
+ }
97
+ .te-sec-delete.btn:hover { background: var(--red-soft); border-color: var(--red); color: var(--red); }
98
+ .te-sec-delete.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 35%, transparent); }
99
+ .te-sec-delete svg { width: 14px; height: 14px; }
100
+ @media (prefers-reduced-motion: reduce) { .te-sec, .te-sec-toggle svg { transition: none; } }
68
101
 
69
102
  /* image field */
70
103
  .te-image { margin-bottom: 12px; }
@@ -152,8 +185,6 @@
152
185
  .te-preset-more summary { cursor: pointer; color: var(--muted); }
153
186
  .te-orphans, .te-sec-body .notice.warn { display: flex; align-items: flex-start; gap: 10px; justify-content: space-between; }
154
187
  .te-orphans .btn, .te-sec-body .notice.warn .btn { flex-shrink: 0; }
155
- .te-publish-one { order: -1; }
156
- .te-sec-foot .te-publish-one + * { margin-left: 0; }
157
188
 
158
189
  /* ---- field types ---- */
159
190
  .te fieldset.field { border: 0; margin: 0 0 14px; padding: 0; min-width: 0; }
@@ -224,7 +255,11 @@
224
255
  .te-pagepick { flex-basis: 100%; }
225
256
  .te-mode { flex-wrap: wrap; }
226
257
  .te-sec-actions .icon-btn { width: 36px; height: 36px; }
227
- .te-sec-foot .btn, .te-savebar .btn, .te-add .btn, .da-keys .btn, .da-token-row .btn { min-height: 36px; }
258
+ .te-sec-body { padding: 14px 12px 12px; }
259
+ .te-sec-foot .te-publish-one { margin-left: 0; flex-basis: 100%; justify-content: center; }
260
+ .te-sec-danger { flex-direction: column; align-items: stretch; gap: 10px; }
261
+ .te-sec-delete.btn { justify-content: center; }
262
+ .te-sec-foot .btn, .te-sec-delete.btn, .te-savebar .btn, .te-add .btn, .da-keys .btn, .da-token-row .btn { min-height: 36px; }
228
263
  .te-sec-body input, .te-sec-body select, .te-sec-body textarea,
229
264
  .te-pagebar select, .te-add select, .da input[type='text'], .da-token-row input { font-size: 16px; }
230
265
  .te-review li { grid-template-columns: minmax(0, 1fr) auto; }
@@ -1,6 +1,7 @@
1
1
  export { ThemeTokens, ThemeManifest, PostOptions, SectionDefinition, SectionInstance, SectionLayout, SectionPages, PagePreset, BUILTIN_SECTIONS, sectionDefinitions, sectionIssues, THEME_API_VERSION } from './contract.mjs';
2
2
  export { formatTime, formatDay } from './fields.mjs';
3
3
  export { upcomingEvents, calendarConfig, eventUrl } from './calendar';
4
+ export { eventImage } from '../lib/calendar/images';
4
5
  export type { UpcomingEventsOptions, UpcomingEvent, ThemeCalendarConfig } from './calendar';
5
6
  export { CONTENT, postsIndexUrl, postUrl, tagUrl, collectionUrl } from '../lib/content';
6
7
  export { formatDate } from '../lib/dates';