@acms-subir/core 0.3.17 → 0.3.19
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 +18 -0
- package/cli/migrations.generated.mjs +23 -1
- package/package.json +1 -1
- package/section-library/upcoming-events/UpcomingEvents.astro +26 -17
- package/section-library/upcoming-events/section.json +41 -0
- package/src/actions/index.ts +5 -1
- package/src/components/CalendarFallbackImage.tsx +18 -0
- package/src/components/EventPage.astro +4 -2
- package/src/components/LinksManager.tsx +86 -55
- package/src/components/ThemeSectionsEditor.tsx +13 -1
- package/src/layouts/Site.astro +1 -0
- package/src/lib/calendar/cards.ts +4 -2
- package/src/lib/calendar/images.ts +17 -0
- package/src/lib/editor/schemas.ts +2 -2
- package/src/lib/editor/site-data.ts +5 -3
- package/src/lib/links-avatar.ts +10 -0
- package/src/lib/maps/providers/geoapify.ts +11 -2
- package/src/lib/maps/providers/locationiq.ts +11 -2
- package/src/lib/mobile/links.ts +4 -0
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +24 -0
- package/src/lib/repo/sql.ts +5 -3
- package/src/lib/repo/types.ts +1 -1
- package/src/lib/schema.ts +13 -0
- package/src/pages/admin/calendar/settings.astro +21 -2
- package/src/pages/admin/links.astro +1 -1
- package/src/pages/api/agent/docs.ts +2 -0
- package/src/pages/links.astro +13 -2
- package/src/styles/admin.css +27 -36
- package/src/styles/page-transitions.css +27 -0
- package/src/styles/site.css +6 -2
- package/src/styles/theme-editor.css +47 -12
- package/src/theme-sdk/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.19
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0817c0e: Preserve street numbers and street names in LocationIQ and Geoapify autocomplete results when saving event locations and reusable places.
|
|
8
|
+
|
|
9
|
+
Keep calendar event popups opaque when an embedded calendar uses a transparent background, with a separate theme customization token.
|
|
10
|
+
|
|
11
|
+
## 0.3.18
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
3
21
|
## 0.3.17
|
|
4
22
|
|
|
5
23
|
### 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,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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
44
|
-
.upev-media img { width: 100%; height: 100%; object-fit:
|
|
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"
|
package/src/actions/index.ts
CHANGED
|
@@ -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
|
-
{
|
|
65
|
-
<figure class="pc-ev-hero"><img src={
|
|
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
|
|
22
|
+
// Edits stay local until "Save & publish" commits them all at once — the live
|
|
22
23
|
// /links page never sees a half-arranged state. Discard restores the saved
|
|
23
24
|
// state.
|
|
24
25
|
|
|
@@ -119,8 +120,13 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
119
120
|
linksName: settings.linksName || settings.siteTitle,
|
|
120
121
|
linksBio: settings.linksBio || settings.tagline,
|
|
121
122
|
linksAvatar: settings.linksAvatar,
|
|
123
|
+
linksAvatarShape: settings.linksAvatarShape,
|
|
124
|
+
linksAvatarFit: settings.linksAvatarFit,
|
|
125
|
+
linksAvatarScale: settings.linksAvatarScale,
|
|
126
|
+
linksVerified: settings.linksVerified,
|
|
122
127
|
};
|
|
123
128
|
const [profile, setProfile] = useState(initialProfile);
|
|
129
|
+
const avatarStyles = linksAvatarStyles(profile);
|
|
124
130
|
// last state the server has — dirty compares against this
|
|
125
131
|
const [saved, setSaved] = useState({ links: initial, profile: initialProfile });
|
|
126
132
|
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
|
|
@@ -265,55 +271,81 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
265
271
|
))}
|
|
266
272
|
</div>
|
|
267
273
|
<div className="lm-editor">
|
|
268
|
-
<
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
className="lm-avatar"
|
|
273
|
-
title={profile.linksAvatar ? 'Change profile photo' : 'Add profile photo'}
|
|
274
|
-
onClick={() => setAvatarPickerOpen(true)}
|
|
275
|
-
>
|
|
276
|
-
{profile.linksAvatar ? (
|
|
277
|
-
<img src={profile.linksAvatar} alt="" />
|
|
278
|
-
) : (
|
|
279
|
-
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4zM12 11a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
|
|
280
|
-
)}
|
|
281
|
-
<span className="lm-avatar-hint">Edit</span>
|
|
282
|
-
</button>
|
|
283
|
-
{profile.linksAvatar && (
|
|
274
|
+
<section className="lm-profile-card" aria-labelledby="links-profile-title">
|
|
275
|
+
<h2 id="links-profile-title">Profile</h2>
|
|
276
|
+
<div className="lm-profile">
|
|
277
|
+
<div className="lm-avatarwrap">
|
|
284
278
|
<button
|
|
285
279
|
type="button"
|
|
286
|
-
className="lm-avatar
|
|
287
|
-
|
|
288
|
-
|
|
280
|
+
className="lm-avatar"
|
|
281
|
+
style={avatarStyles.frame}
|
|
282
|
+
aria-label={profile.linksAvatar ? 'Change profile photo' : 'Upload profile photo'}
|
|
283
|
+
onClick={() => setAvatarPickerOpen(true)}
|
|
289
284
|
>
|
|
290
|
-
|
|
285
|
+
{profile.linksAvatar ? (
|
|
286
|
+
<img src={profile.linksAvatar} alt="" style={avatarStyles.image} />
|
|
287
|
+
) : (
|
|
288
|
+
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4zM12 11a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
|
|
289
|
+
)}
|
|
291
290
|
</button>
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
291
|
+
<button type="button" className="btn sm" onClick={() => setAvatarPickerOpen(true)}>
|
|
292
|
+
{profile.linksAvatar ? 'Change photo' : 'Upload photo'}
|
|
293
|
+
</button>
|
|
294
|
+
{profile.linksAvatar && (
|
|
295
|
+
<button type="button" className="btn sm ghost" onClick={() => editProfile({ linksAvatar: '' })}>Remove photo</button>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
<div className="lm-profile-fields">
|
|
299
|
+
<fieldset className="lm-photo-controls">
|
|
300
|
+
<legend>Photo appearance</legend>
|
|
301
|
+
<div className="lm-photo-options">
|
|
302
|
+
<label htmlFor="links-avatar-shape">Shape
|
|
303
|
+
<select id="links-avatar-shape" value={profile.linksAvatarShape} onChange={(e) => editProfile({ linksAvatarShape: e.target.value as Settings['linksAvatarShape'] })}>
|
|
304
|
+
<option value="circle">Circle</option>
|
|
305
|
+
<option value="square">Square</option>
|
|
306
|
+
</select>
|
|
307
|
+
</label>
|
|
308
|
+
<label htmlFor="links-avatar-fit">Image fit
|
|
309
|
+
<select id="links-avatar-fit" value={profile.linksAvatarFit} onChange={(e) => editProfile({ linksAvatarFit: e.target.value as Settings['linksAvatarFit'] })}>
|
|
310
|
+
<option value="cover">Fill frame</option>
|
|
311
|
+
<option value="contain">Fit whole image</option>
|
|
312
|
+
</select>
|
|
313
|
+
</label>
|
|
314
|
+
</div>
|
|
315
|
+
<label htmlFor="links-avatar-scale">Image scale <output htmlFor="links-avatar-scale">{profile.linksAvatarScale}%</output></label>
|
|
316
|
+
<input id="links-avatar-scale" type="range" min="25" max="200" step="1" value={profile.linksAvatarScale} aria-describedby="links-avatar-help" onChange={(e) => editProfile({ linksAvatarScale: Number(e.target.value) })} />
|
|
317
|
+
<p id="links-avatar-help">For logos, choose Fit whole image and reduce the scale to leave space around the edges. The thumbnail updates as you adjust.</p>
|
|
318
|
+
<button type="button" className="btn sm ghost" onClick={() => editProfile({ linksAvatarShape: 'circle', linksAvatarFit: 'cover', linksAvatarScale: 100 })}>Reset photo appearance</button>
|
|
319
|
+
</fieldset>
|
|
320
|
+
<label htmlFor="links-profile-name">Name or handle</label>
|
|
321
|
+
<input
|
|
322
|
+
id="links-profile-name"
|
|
323
|
+
type="text"
|
|
324
|
+
className="lm-profile-name"
|
|
325
|
+
placeholder="Your name or @username"
|
|
326
|
+
value={profile.linksName}
|
|
327
|
+
onChange={(e) => editProfile({ linksName: e.target.value })}
|
|
328
|
+
/>
|
|
329
|
+
<label htmlFor="links-profile-bio">Bio</label>
|
|
330
|
+
<textarea
|
|
331
|
+
id="links-profile-bio"
|
|
332
|
+
className="lm-profile-bio"
|
|
333
|
+
rows={2}
|
|
334
|
+
placeholder="Write a short bio…"
|
|
335
|
+
value={profile.linksBio}
|
|
336
|
+
onChange={(e) => {
|
|
337
|
+
e.target.style.height = 'auto';
|
|
338
|
+
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
339
|
+
editProfile({ linksBio: e.target.value });
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
<label className="lm-verification">
|
|
343
|
+
<input type="checkbox" role="switch" checked={profile.linksVerified} onChange={(e) => editProfile({ linksVerified: e.target.checked })} />
|
|
344
|
+
<span>Show verification badge<small>Display a check badge beside your name.</small></span>
|
|
345
|
+
</label>
|
|
346
|
+
</div>
|
|
315
347
|
</div>
|
|
316
|
-
</
|
|
348
|
+
</section>
|
|
317
349
|
|
|
318
350
|
<div className="lm-addwrap">
|
|
319
351
|
<button
|
|
@@ -418,8 +450,9 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
418
450
|
</a>
|
|
419
451
|
</div>
|
|
420
452
|
<div className="lm-phone">
|
|
421
|
-
<iframe key={tick} src="/links" title="
|
|
453
|
+
<iframe key={tick} src="/links" title="Published links page" />
|
|
422
454
|
</div>
|
|
455
|
+
<p className="lm-stat">Published page · refreshes after saving</p>
|
|
423
456
|
<p className="lm-stat"><b>{totalClicks.toLocaleString()}</b> total clicks · <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>{saveLabel}</span></p>
|
|
424
457
|
</aside>
|
|
425
458
|
|
|
@@ -435,15 +468,13 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
435
468
|
))}
|
|
436
469
|
</section>
|
|
437
470
|
|
|
438
|
-
|
|
439
|
-
<
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
</div>
|
|
446
|
-
)}
|
|
471
|
+
<div className="lm-savebar" role="status">
|
|
472
|
+
<span>{saveState === 'error' ? 'Save failed — try again' : dirty ? 'Unsaved changes' : 'Page is live'}</span>
|
|
473
|
+
<button type="button" className="btn sm" onClick={discard} disabled={!dirty || saveState === 'saving'}>Discard</button>
|
|
474
|
+
<button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={!dirty || saveState === 'saving'}>
|
|
475
|
+
{saveState === 'saving' ? 'Saving…' : 'Save & publish'}
|
|
476
|
+
</button>
|
|
477
|
+
</div>
|
|
447
478
|
</div>
|
|
448
479
|
);
|
|
449
480
|
}
|
|
@@ -790,6 +790,7 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
|
|
|
790
790
|
aria-controls={panelId}
|
|
791
791
|
onClick={() => setOpenId(open ? null : s.id)}
|
|
792
792
|
>
|
|
793
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
|
793
794
|
<span className="te-sec-name">{name}</span>
|
|
794
795
|
<span className="te-sec-meta">
|
|
795
796
|
{s.variant !== 'default' && s.variant}
|
|
@@ -927,7 +928,6 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
|
|
|
927
928
|
<button type="button" onClick={() => patchSection(s.id, (x) => ({ ...x, locked: x.locked ? undefined : true }))}>{s.locked ? 'Unlock for editors' : 'Lock for editors'}</button>
|
|
928
929
|
</div>
|
|
929
930
|
</details>
|
|
930
|
-
<button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
|
|
931
931
|
</>}
|
|
932
932
|
{canPublishAlone && changedFromLive && layout.mode === 'sections' && liveLayout?.mode === 'sections' && (
|
|
933
933
|
<button className="btn sm primary te-publish-one" type="button" onClick={() => void publishSection(s)} disabled={saving || conflict || issueCount > 0} title={issueCount ? 'Fix the fields marked below first' : undefined}>
|
|
@@ -935,6 +935,18 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled,
|
|
|
935
935
|
</button>
|
|
936
936
|
)}
|
|
937
937
|
</div>
|
|
938
|
+
{isOwner && (
|
|
939
|
+
<div className="te-sec-danger" role="group" aria-labelledby={`${panelId}-del`}>
|
|
940
|
+
<p className="te-sec-danger-text">
|
|
941
|
+
<b id={`${panelId}-del`}>Delete section</b>
|
|
942
|
+
<span>Removes {name} from this page’s draft. The live page changes only when you publish.</span>
|
|
943
|
+
</p>
|
|
944
|
+
<button className="btn sm te-delete te-sec-delete" type="button" onClick={() => remove(s)} aria-label={`Delete ${name} section`}>
|
|
945
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
|
|
946
|
+
Delete
|
|
947
|
+
</button>
|
|
948
|
+
</div>
|
|
949
|
+
)}
|
|
938
950
|
</div>
|
|
939
951
|
)}
|
|
940
952
|
</>
|
package/src/layouts/Site.astro
CHANGED
|
@@ -18,6 +18,7 @@ import type { SiteChrome } from '../lib/site-chrome';
|
|
|
18
18
|
import SiteLogo from '../components/site/SiteLogo.astro';
|
|
19
19
|
import ClerkSession from '../components/auth/ClerkSession.astro';
|
|
20
20
|
import '../styles/site.css';
|
|
21
|
+
import '../styles/page-transitions.css';
|
|
21
22
|
import '../styles/cards.css';
|
|
22
23
|
import '../styles/live.css';
|
|
23
24
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ContentRepository } from '../repo/types';
|
|
5
5
|
import type { CalEvent, Settings } from '../schema';
|
|
6
6
|
import { siteLocale } from '../dates';
|
|
7
|
+
import { eventImage } from './images';
|
|
7
8
|
import { eventUrl } from './config';
|
|
8
9
|
import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
|
|
9
10
|
import { collectViewEvents } from './data';
|
|
@@ -36,9 +37,10 @@ export function eventCardHtml(
|
|
|
36
37
|
const h = opts.heading ?? 'h3';
|
|
37
38
|
// "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25"
|
|
38
39
|
const when = f.range;
|
|
40
|
+
const image = eventImage(e, settings);
|
|
39
41
|
const media = opts.showImage
|
|
40
|
-
?
|
|
41
|
-
? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(
|
|
42
|
+
? image.src
|
|
43
|
+
? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(image.src)}" alt="${esc(image.alt)}" loading="lazy" /></a>`
|
|
42
44
|
: `<a class="pc-ev-card-media is-tile" href="${esc(href)}" tabindex="-1" aria-hidden="true"><span class="pc-ev-tile"><b>${esc(f.month)}</b><span>${esc(f.day)}</span></span></a>`
|
|
43
45
|
: '';
|
|
44
46
|
const loc = locationLine(e.location);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CalEvent, Settings } from '../schema';
|
|
2
|
+
|
|
3
|
+
/** Only image URLs that can be placed in an img src; blank clears the fallback. */
|
|
4
|
+
export function safeEventImageUrl(value: unknown): string {
|
|
5
|
+
const url = typeof value === 'string' ? value.trim() : '';
|
|
6
|
+
if (!url || /[\\\x00-\x20]/.test(url)) return '';
|
|
7
|
+
if (/^\/(?!\/)/.test(url)) return url;
|
|
8
|
+
try { return /^https?:$/.test(new URL(url).protocol) ? url : ''; } catch { return ''; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Authored image → section override → calendar fallback. Never modifies the event. */
|
|
12
|
+
export function eventImage(event: Pick<CalEvent, 'cover' | 'coverAlt'>, settings: Pick<Settings, 'calendar'>, sectionFallback = '') {
|
|
13
|
+
const cover = safeEventImageUrl(event.cover);
|
|
14
|
+
return cover
|
|
15
|
+
? { src: cover, alt: event.coverAlt }
|
|
16
|
+
: { src: safeEventImageUrl(sectionFallback) || safeEventImageUrl(settings.calendar.eventFallbackImage), alt: '' };
|
|
17
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { FormDefinition } from '../forms/schema';
|
|
3
|
-
import { Booking, BookingLink, Calendar, LinkKind, Resource } from '../schema';
|
|
3
|
+
import { Booking, BookingLink, Calendar, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, Resource } from '../schema';
|
|
4
4
|
|
|
5
5
|
// Response shapes of the editor-app API (OpenAPI components, client types).
|
|
6
6
|
const str = z.string(), bool = z.boolean(), num = z.number(), date = str.datetime();
|
|
@@ -115,7 +115,7 @@ export const EditorFormResponses = z.object({
|
|
|
115
115
|
items: z.array(z.object({ id: str, createdAt: date, answers: z.array(z.object({ label: str, value: str })) })),
|
|
116
116
|
pagination: Pagination,
|
|
117
117
|
});
|
|
118
|
-
export const EditorLinksPage = z.object({ revision: num.int(), name: str, bio: str, avatar: str, canEditHtml: bool, items: z.array(z.object({
|
|
118
|
+
export const EditorLinksPage = z.object({ revision: num.int(), name: str, bio: str, avatar: str, avatarShape: LinksAvatarShape, avatarFit: LinksAvatarFit, avatarScale: LinksAvatarScale, verified: bool, canEditHtml: bool, items: z.array(z.object({
|
|
119
119
|
id: str, kind: LinkKind, label: str, url: str, icon: str, html: str, meta: z.record(z.unknown()), clicks: num.int(),
|
|
120
120
|
})) });
|
|
121
121
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { Link } from '../schema';
|
|
3
|
-
import { LinkKind } from '../schema';
|
|
3
|
+
import { LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { displayAnswers } from '../forms/service';
|
|
6
6
|
import type { EditorContext } from './http';
|
|
@@ -56,7 +56,7 @@ export async function getLinksFor(c: EditorContext) {
|
|
|
56
56
|
// next save gets a 409), never newer than the blocks it is sent with
|
|
57
57
|
const revision = await c.repo.linksRevision();
|
|
58
58
|
const [settings, links] = await Promise.all([c.repo.getSettings(), c.repo.listLinks()]);
|
|
59
|
-
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, canEditHtml: isAdmin(c.who) && await principalCan(c.repo, c.who, 'site.links'), items: links.map(linkView) };
|
|
59
|
+
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, avatarShape: settings.linksAvatarShape, avatarFit: settings.linksAvatarFit, avatarScale: settings.linksAvatarScale, verified: settings.linksVerified, canEditHtml: isAdmin(c.who) && await principalCan(c.repo, c.who, 'site.links'), items: links.map(linkView) };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const Item = z.object({
|
|
@@ -71,6 +71,8 @@ export const LinksPut = z.object({
|
|
|
71
71
|
revision: z.number().int().min(0),
|
|
72
72
|
name: z.string().max(120).optional(), bio: z.string().max(500).optional(),
|
|
73
73
|
avatar: z.string().max(1000).refine((v) => !v || /^(https?:\/\/|\/)/.test(v), 'Use an http(s) or site path image.').optional(),
|
|
74
|
+
avatarShape: LinksAvatarShape.optional(), avatarFit: LinksAvatarFit.optional(), avatarScale: LinksAvatarScale.optional(),
|
|
75
|
+
verified: z.boolean().optional(),
|
|
74
76
|
// the whole page in display order; blocks left out are removed
|
|
75
77
|
items: z.array(Item).max(200),
|
|
76
78
|
}).strict();
|
|
@@ -107,6 +109,6 @@ export async function putLinksFor(c: EditorContext, body: z.infer<typeof LinksPu
|
|
|
107
109
|
await c.repo.replaceLinks(body.revision, body.items.map((i) => {
|
|
108
110
|
const old = i.id ? byId.get(i.id) : undefined;
|
|
109
111
|
return { kind: i.kind, label: i.label, url: i.url, icon: i.icon, html: i.html, meta: i.meta, keep: old && old.kind === i.kind ? old.id : undefined };
|
|
110
|
-
}), { linksName: body.name, linksBio: body.bio, linksAvatar: body.avatar });
|
|
112
|
+
}), { linksName: body.name, linksBio: body.bio, linksAvatar: body.avatar, linksVerified: body.verified, linksAvatarShape: body.avatarShape, linksAvatarFit: body.avatarFit, linksAvatarScale: body.avatarScale });
|
|
111
113
|
return getLinksFor(c);
|
|
112
114
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Settings } from './schema';
|
|
2
|
+
|
|
3
|
+
type AvatarSettings = Pick<Settings, 'linksAvatarShape' | 'linksAvatarFit' | 'linksAvatarScale'>;
|
|
4
|
+
|
|
5
|
+
export function linksAvatarStyles(settings: AvatarSettings) {
|
|
6
|
+
return {
|
|
7
|
+
frame: { borderRadius: settings.linksAvatarShape === 'square' ? '0' : '50%' },
|
|
8
|
+
image: { objectFit: settings.linksAvatarFit, transform: `scale(${settings.linksAvatarScale / 100})` },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// Geoapify (OpenStreetMap data): autocomplete, reverse geocoding and static
|
|
2
2
|
// maps with one API key. https://apidocs.geoapify.com
|
|
3
3
|
import type { GeoResult } from '../types';
|
|
4
|
-
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
4
|
+
import { arr, geo, getImage, getJson, joinParts, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
5
5
|
|
|
6
6
|
export function geoapifyResults(json: unknown): GeoResult[] {
|
|
7
7
|
return arr(obj(json).results).map((raw) => {
|
|
8
8
|
const r = obj(raw);
|
|
9
9
|
const name = s(r.name);
|
|
10
|
-
|
|
10
|
+
const street = s(r.street);
|
|
11
|
+
const number = s(r.housenumber);
|
|
12
|
+
// A result's name can itself be the street address. Do not let geo()
|
|
13
|
+
// strip it as a duplicated venue name from the full address.
|
|
14
|
+
const isStreet = street && [street, `${number} ${street}`.trim(), `${street} ${number}`.trim()]
|
|
15
|
+
.some((part) => part.toLowerCase() === name.toLowerCase());
|
|
16
|
+
// address_line1 may hold the street even for a named place; line2 alone
|
|
17
|
+
// can be just the locality. Prefer the provider's locale-aware formatting.
|
|
18
|
+
const address = s(r.formatted) || joinParts(r.address_line1, r.address_line2);
|
|
19
|
+
return geo(isStreet ? '' : name, address, r.lat, r.lon);
|
|
11
20
|
});
|
|
12
21
|
}
|
|
13
22
|
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// LocationIQ (OpenStreetMap data): autocomplete, reverse and static maps.
|
|
2
2
|
import type { GeoResult } from '../types';
|
|
3
|
-
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
3
|
+
import { arr, geo, getImage, getJson, joinParts, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
4
4
|
|
|
5
5
|
export function locationiqResults(json: unknown): GeoResult[] {
|
|
6
6
|
const rows = Array.isArray(json) ? json : [json];
|
|
7
7
|
return rows.map((raw) => {
|
|
8
8
|
const r = obj(raw);
|
|
9
9
|
const place = s(r.display_place);
|
|
10
|
-
|
|
10
|
+
const parts = obj(r.address);
|
|
11
|
+
const road = s(parts.road);
|
|
12
|
+
const number = s(parts.house_number);
|
|
13
|
+
const normalize = (value: string) => value.replace(/[,\s]+/g, ' ').trim().toLowerCase();
|
|
14
|
+
// display_place is not always a venue: it can be a house number + road,
|
|
15
|
+
// and display_address deliberately omits everything in display_place.
|
|
16
|
+
const isStreet = (r.class === 'highway' && !s(parts.name)) || r.type === 'house' || r.type === 'address'
|
|
17
|
+
|| (road && [road, `${number} ${road}`, `${road} ${number}`].some((part) => normalize(part) === normalize(place)));
|
|
18
|
+
const address = s(r.display_name) || joinParts(place, r.display_address);
|
|
19
|
+
return geo(isStreet ? '' : place, address, r.lat, r.lon);
|
|
11
20
|
}).filter((r) => r.lat !== null || r.address || r.name);
|
|
12
21
|
}
|
|
13
22
|
|
package/src/lib/mobile/links.ts
CHANGED
|
@@ -62,6 +62,10 @@ export async function getLinks(repo: ContentRepository, url: URL) {
|
|
|
62
62
|
name: settings.linksName || settings.siteTitle,
|
|
63
63
|
bio: settings.linksBio || settings.tagline,
|
|
64
64
|
avatar: safeUrl(settings.linksAvatar, origin),
|
|
65
|
+
avatarShape: settings.linksAvatarShape,
|
|
66
|
+
avatarFit: settings.linksAvatarFit,
|
|
67
|
+
avatarScale: settings.linksAvatarScale,
|
|
68
|
+
verified: settings.linksVerified,
|
|
65
69
|
url: page,
|
|
66
70
|
socials: nav.filter((n) => n.area === 'social').map((n) => ({ label: n.label, url: safeUrl(n.url, origin, true), iconPath: socialIconPath(n.url) })).filter((n) => n.url),
|
|
67
71
|
items: links.filter(shown).map((l) => item(l, origin, page)).filter((i) => !('url' in i) || i.url),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { CalEvent, Booking, Calendar, BookingQuestion } from '../schema';
|
|
2
|
+
import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale } from '../schema';
|
|
3
3
|
|
|
4
4
|
const str = z.string(), bool = z.boolean(), strings = z.array(str);
|
|
5
5
|
const date = str.datetime(), locale = str.nullable();
|
|
@@ -45,7 +45,7 @@ export const SearchResults = z.object({ locale, query: str,
|
|
|
45
45
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
|
46
46
|
const ButtonStyle = { color: str.nullable(), textColor: str.nullable() };
|
|
47
47
|
export const Links = z.object({
|
|
48
|
-
name: str, bio: str, avatar: str, url: str,
|
|
48
|
+
name: str, bio: str, avatar: str, avatarShape: LinksAvatarShape, avatarFit: LinksAvatarFit, avatarScale: LinksAvatarScale, verified: bool, url: str,
|
|
49
49
|
socials: z.array(z.object({ label: str, url: str, iconPath: str.nullable() })),
|
|
50
50
|
items: z.array(z.discriminatedUnion('kind', [
|
|
51
51
|
z.object({ id: str, kind: z.literal('link'), label: str, url: str, destination: str, icon: Icon, ...ButtonStyle }),
|
|
@@ -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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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 {
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|
|
16
|
+
<div><h1>Links page</h1><p class="sub">Your link-in-bio page at /links. Save & 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)
|
package/src/pages/links.astro
CHANGED
|
@@ -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 &&
|
|
98
|
-
|
|
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">
|
package/src/styles/admin.css
CHANGED
|
@@ -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
|
|
522
|
-
.lm-profile {
|
|
523
|
-
.lm-
|
|
524
|
-
.lm-
|
|
525
|
-
|
|
526
|
-
|
|
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-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
.lm-
|
|
537
|
-
.lm-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
.lm-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
.lm-profile-
|
|
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
|
+
}
|
package/src/styles/site.css
CHANGED
|
@@ -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%;
|
|
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 {
|
|
@@ -582,6 +584,8 @@ li::marker { color: var(--muted); }
|
|
|
582
584
|
@layer base {
|
|
583
585
|
.pc-calendar {
|
|
584
586
|
--cal-bg: var(--panel);
|
|
587
|
+
/* The calendar canvas may be transparent; floating details need a surface. */
|
|
588
|
+
--cal-pop-bg: var(--panel);
|
|
585
589
|
--cal-ink: var(--ink);
|
|
586
590
|
--cal-muted: var(--muted);
|
|
587
591
|
--cal-line: var(--line);
|
|
@@ -673,7 +677,7 @@ li::marker { color: var(--muted); }
|
|
|
673
677
|
/* event details popover */
|
|
674
678
|
.pc-cal-pop {
|
|
675
679
|
position: absolute; z-index: 5; max-width: 300px; padding: 12px 14px 12px;
|
|
676
|
-
background: var(--cal-bg); border: 1px solid var(--cal-line); border-radius: var(--r-sm);
|
|
680
|
+
background: var(--cal-pop-bg); border: 1px solid var(--cal-line); border-radius: var(--r-sm);
|
|
677
681
|
box-shadow: var(--sh-3); font-size: 12.5px; color: var(--cal-ink);
|
|
678
682
|
}
|
|
679
683
|
.pc-cal-pop-title { display: block; font-size: 13.5px; padding-right: 20px; }
|
|
@@ -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
|
-
|
|
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
|
|
44
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
67
|
-
.te-sec-foot
|
|
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-
|
|
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; }
|
package/src/theme-sdk/index.ts
CHANGED
|
@@ -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';
|