@acms-subir/core 0.3.6 → 0.3.8
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 +27 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EmailSettings.tsx +6 -0
- package/src/components/EventPage.astro +25 -5
- package/src/components/booking/DurationEditor.astro +156 -0
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/forms.ts +67 -0
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/drafts.ts +9 -1
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/email/readiness.ts +14 -0
- package/src/lib/email/service.ts +8 -3
- package/src/lib/email/templates.ts +8 -2
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +99 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +38 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +23 -12
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/resources/link.astro +95 -49
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/admin/settings.astro +1 -1
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/email/settings.ts +4 -2
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
|
@@ -16,6 +16,7 @@ import { PostListCard } from './extensions/postList';
|
|
|
16
16
|
import { ToggleCard } from './extensions/toggle';
|
|
17
17
|
import { TocCard } from './extensions/toc';
|
|
18
18
|
import { CalendarCard } from './extensions/calendar';
|
|
19
|
+
import { FormCard } from './extensions/form';
|
|
19
20
|
import { EventCard } from './extensions/events';
|
|
20
21
|
import { InfoCards } from './extensions/infoCards';
|
|
21
22
|
import { SlashMenu, type SlashSnippet } from './extensions/slash';
|
|
@@ -30,7 +31,10 @@ import { CONTENT, collectionUrl } from '../../lib/content';
|
|
|
30
31
|
import { I18N, localeHomeUrl } from '../../lib/i18n';
|
|
31
32
|
import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../lib/drafts';
|
|
32
33
|
import { CALENDAR, eventUrl, type EventFieldConfig } from '../../lib/calendar/config';
|
|
33
|
-
import {
|
|
34
|
+
import type { CalendarOption, EventMeta } from './EventPanel';
|
|
35
|
+
import { EventSheet } from './event/EventSheet';
|
|
36
|
+
import { focusEventCard } from './event/Section';
|
|
37
|
+
import type { MapsStatus } from '../../lib/maps/types';
|
|
34
38
|
import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
|
|
35
39
|
|
|
36
40
|
// The Ghost-style writing surface, shared by posts AND pages: distraction-free
|
|
@@ -53,6 +57,8 @@ interface Props {
|
|
|
53
57
|
calendars?: CalendarOption[];
|
|
54
58
|
eventFields?: EventFieldConfig[];
|
|
55
59
|
siteTimezone?: string;
|
|
60
|
+
// the site's maps provider (address suggestions, static map images)
|
|
61
|
+
maps?: MapsStatus;
|
|
56
62
|
initialHTML: string;
|
|
57
63
|
unsplashEnabled: boolean;
|
|
58
64
|
previewPath: string;
|
|
@@ -146,14 +152,17 @@ type TrField = 'title' | 'excerpt' | 'bodyHtml' | 'seoTitle' | 'seoDesc' | 'loca
|
|
|
146
152
|
const emptyTr = () => ({ title: '', excerpt: '', bodyHtml: '', seoTitle: '', seoDesc: '', slug: '', locationName: '', ctaLabel: '', meta: {} as Record<string, { state: 'ai' | 'reviewed'; src: string }> });
|
|
147
153
|
const TR_STATE: Record<string, string> = { missing: 'not translated', ai: 'AI draft', reviewed: 'reviewed', outdated: 'English changed' };
|
|
148
154
|
|
|
155
|
+
const NO_LOCATION: EventMeta['location'] = { name: '', address: '', url: '', lat: null, lng: null, placeId: '', showMap: true, mapImage: '', mapImageDark: '' };
|
|
156
|
+
const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: false, staticMaps: false };
|
|
157
|
+
|
|
149
158
|
const NO_EVENT: EventMeta = {
|
|
150
159
|
start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
|
|
151
|
-
location:
|
|
160
|
+
location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
152
161
|
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
|
|
153
162
|
roomHold: NO_HOLD,
|
|
154
163
|
};
|
|
155
164
|
|
|
156
|
-
export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
|
|
165
|
+
export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', maps = NO_MAPS, initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
|
|
157
166
|
const doc = (kind === 'page' ? page : kind === 'event' ? event : post)!;
|
|
158
167
|
// posts and events share the feature image, excerpt, tags and "featured"
|
|
159
168
|
const postLike = kind !== 'page';
|
|
@@ -177,7 +186,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
177
186
|
status: event!.status, featured: event!.featured, isHome: false, locale: '', authorId: null, authorIds: [],
|
|
178
187
|
template: event!.template, publishedAt: event!.publishedAt, scheduledAt: event!.scheduledAt,
|
|
179
188
|
start: event!.start, end: event!.end, allDay: event!.allDay, timezone: event!.timezone, rrule: event!.rrule,
|
|
180
|
-
exdates: event!.exdates, cancelled: event!.cancelled, location: event!.location, cta: event!.cta,
|
|
189
|
+
exdates: event!.exdates, cancelled: event!.cancelled, location: { ...NO_LOCATION, ...event!.location }, cta: event!.cta,
|
|
181
190
|
calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp, roomHold: event!.roomHold,
|
|
182
191
|
}
|
|
183
192
|
: {
|
|
@@ -196,6 +205,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
196
205
|
: kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
|
|
197
206
|
const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar' : `/admin/c/${meta.collection}`;
|
|
198
207
|
const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
|
|
208
|
+
// events keep their settings on the page; the sidebar is just URL & SEO
|
|
209
|
+
const sidebarLabel = kind === 'page' ? 'Page settings' : kind === 'event' ? 'URL & SEO' : `${col.singular} settings`;
|
|
199
210
|
|
|
200
211
|
const [authors, setAuthors] = useState<AuthorOption[]>([]);
|
|
201
212
|
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
|
@@ -211,8 +222,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
211
222
|
const [leaveBusy, setLeaveBusy] = useState('');
|
|
212
223
|
const [saveState, setSaveState] = useState<SaveState>('saved');
|
|
213
224
|
const saveStateRef = useRef<SaveState>('saved');
|
|
214
|
-
//
|
|
215
|
-
const [sidebarOpen, setSidebarOpen] = useState(
|
|
225
|
+
// events show their settings on the page; the sidebar is URL & SEO only
|
|
226
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
216
227
|
const [publishOpen, setPublishOpen] = useState(false);
|
|
217
228
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
218
229
|
const [split, setSplit] = useState(() => {
|
|
@@ -295,6 +306,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
295
306
|
TocCard,
|
|
296
307
|
CalendarCard,
|
|
297
308
|
EventCard,
|
|
309
|
+
FormCard,
|
|
298
310
|
InfoCards,
|
|
299
311
|
FileCard,
|
|
300
312
|
BookmarkCard,
|
|
@@ -417,6 +429,15 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
417
429
|
if (r.error) {
|
|
418
430
|
setSave('error');
|
|
419
431
|
setSaveError(r.error.message);
|
|
432
|
+
// point at the section the problem is in, unless the person is
|
|
433
|
+
// typing in the settings already (autosave would yank the caret)
|
|
434
|
+
const msg = r.error.message.toLowerCase();
|
|
435
|
+
const typing = document.activeElement?.closest('.pe-evs');
|
|
436
|
+
if (!typing) {
|
|
437
|
+
if (/\b(button|cta|rsvp|seats?)\b/.test(msg)) focusEventCard('ev-registration');
|
|
438
|
+
else if (/\b(location|map|place|meeting)\b/.test(msg)) focusEventCard('ev-where');
|
|
439
|
+
else if (/\b(rrule|repeats?|dates?|times?|ends?|zone|timezone)\b|\bstart (date|time)|\bstarts\b/.test(msg)) focusEventCard('ev-when');
|
|
440
|
+
}
|
|
420
441
|
return;
|
|
421
442
|
}
|
|
422
443
|
setSaveError('');
|
|
@@ -424,7 +445,12 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
424
445
|
const ev = mergeEventDraft(r.data.event);
|
|
425
446
|
setPending(Boolean(r.data.event.draft));
|
|
426
447
|
setMetaState((prev) => {
|
|
427
|
-
|
|
448
|
+
// map images are made server-side for the saved pin; take them only
|
|
449
|
+
// if the pin hasn't moved since this save started
|
|
450
|
+
const loc = { ...NO_LOCATION, ...ev.location };
|
|
451
|
+
const samePin = loc.lat === prev.location.lat && loc.lng === prev.location.lng;
|
|
452
|
+
const location = samePin ? { ...prev.location, mapImage: loc.mapImage, mapImageDark: loc.mapImageDark } : prev.location;
|
|
453
|
+
const next = { ...prev, slug: ev.slug, start: ev.start, end: ev.end, publishedAt: ev.publishedAt, location };
|
|
428
454
|
metaRef.current = next;
|
|
429
455
|
return next;
|
|
430
456
|
});
|
|
@@ -619,7 +645,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
619
645
|
}, []);
|
|
620
646
|
|
|
621
647
|
// Authors load up front (the byline under the title names the primary one);
|
|
622
|
-
// the tag registry only matters once
|
|
648
|
+
// the tag registry only matters once tags are on screen (sidebar, or the
|
|
649
|
+
// event sheet's Organize card).
|
|
623
650
|
useEffect(() => {
|
|
624
651
|
if (kind !== 'post') return;
|
|
625
652
|
actions.users.list({}).then((r) => {
|
|
@@ -628,7 +655,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
628
655
|
}, [kind]);
|
|
629
656
|
const tagsLoaded = useRef(false);
|
|
630
657
|
useEffect(() => {
|
|
631
|
-
if (!postLike || !sidebarOpen || tagsLoaded.current) return;
|
|
658
|
+
if (!postLike || (!sidebarOpen && kind !== 'event') || tagsLoaded.current) return;
|
|
632
659
|
tagsLoaded.current = true;
|
|
633
660
|
actions.tags.list({}).then((r) => {
|
|
634
661
|
if (!r.error) setTagSuggestions(r.data.tags.map((t) => t.name));
|
|
@@ -1098,8 +1125,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1098
1125
|
<button
|
|
1099
1126
|
type="button"
|
|
1100
1127
|
className={`pe-btn ghost sm pe-icon-btn pe-sidebar-toggle ${sidebarOpen ? 'is-active' : ''}`}
|
|
1101
|
-
title={
|
|
1102
|
-
aria-label={
|
|
1128
|
+
title={sidebarLabel}
|
|
1129
|
+
aria-label={sidebarLabel}
|
|
1103
1130
|
onClick={() => setSidebarOpen((v) => !v)}
|
|
1104
1131
|
>
|
|
1105
1132
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -1112,7 +1139,24 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1112
1139
|
<div className="pe-body">
|
|
1113
1140
|
<section className="pe-editor-pane">
|
|
1114
1141
|
<main className="pe-main">
|
|
1115
|
-
|
|
1142
|
+
{kind === 'event' && (
|
|
1143
|
+
<div className="pe-evs-wrap">
|
|
1144
|
+
<EventSheet
|
|
1145
|
+
meta={meta}
|
|
1146
|
+
onChange={(patch) => setMeta(patch)}
|
|
1147
|
+
calendars={calendars}
|
|
1148
|
+
fields={eventFields}
|
|
1149
|
+
siteTimezone={siteTimezone}
|
|
1150
|
+
maps={maps}
|
|
1151
|
+
eventId={docId}
|
|
1152
|
+
title={meta.title}
|
|
1153
|
+
status={meta.status}
|
|
1154
|
+
tags={<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />}
|
|
1155
|
+
/>
|
|
1156
|
+
</div>
|
|
1157
|
+
)}
|
|
1158
|
+
<div className={`pe-column ${kind === 'event' ? 'pe-ev-column' : ''}`}>
|
|
1159
|
+
{kind === 'event' && <div className="pe-evs-divider" id="ev-page"><span>Event page</span></div>}
|
|
1116
1160
|
{postLike && (meta.cover ? (
|
|
1117
1161
|
<figure className="pe-feature">
|
|
1118
1162
|
<img src={meta.cover} alt={meta.coverAlt} />
|
|
@@ -1213,24 +1257,6 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1213
1257
|
/>
|
|
1214
1258
|
)}
|
|
1215
1259
|
|
|
1216
|
-
{kind === 'event' && (
|
|
1217
|
-
<div className="pe-byline pe-ev-byline">
|
|
1218
|
-
<button type="button" className={`pe-ev-when ${meta.cancelled ? 'is-cancelled' : ''}`} title="Edit date & time" onClick={() => setSidebarOpen(true)}>
|
|
1219
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="16" rx="2" /><path d="M3 9.5h18M8 2.5v4M16 2.5v4" /></svg>
|
|
1220
|
-
{meta.cancelled && <b>Cancelled · </b>}{eventWhenLabel(meta)}
|
|
1221
|
-
</button>
|
|
1222
|
-
{meta.rrule && <span className="pe-byline-meta">Repeats</span>}
|
|
1223
|
-
{meta.location.name ? (
|
|
1224
|
-
<button type="button" className="pe-byline-tag" title="Edit location" onClick={() => setSidebarOpen(true)}>{meta.location.name}</button>
|
|
1225
|
-
) : (
|
|
1226
|
-
<button type="button" className="pe-byline-add" onClick={() => setSidebarOpen(true)}>+ Location</button>
|
|
1227
|
-
)}
|
|
1228
|
-
{calendars.filter((c) => meta.calendarIds.includes(c.id)).map((c) => (
|
|
1229
|
-
<button key={c.id} type="button" className="pe-byline-tag pe-ev-cal" onClick={() => setSidebarOpen(true)}><i style={{ background: c.color }} aria-hidden="true" />{c.name}</button>
|
|
1230
|
-
))}
|
|
1231
|
-
</div>
|
|
1232
|
-
)}
|
|
1233
|
-
|
|
1234
1260
|
{kind === 'post' && (
|
|
1235
1261
|
<div className="pe-byline">
|
|
1236
1262
|
{primaryAuthor && <span className="pe-byline-author"><i aria-hidden="true">{primaryAuthor.slice(0, 1).toUpperCase()}</i>{primaryAuthor}</span>}
|
|
@@ -1428,9 +1454,9 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1428
1454
|
</div>
|
|
1429
1455
|
|
|
1430
1456
|
{sidebarOpen && (
|
|
1431
|
-
<aside className="pe-sidebar" aria-label={
|
|
1457
|
+
<aside className="pe-sidebar" aria-label={sidebarLabel}>
|
|
1432
1458
|
<div className="pe-sidebar-head">
|
|
1433
|
-
<span>{
|
|
1459
|
+
<span>{sidebarLabel}</span>
|
|
1434
1460
|
<button type="button" className="pe-btn ghost sm pe-icon-btn" aria-label="Close settings" onClick={() => setSidebarOpen(false)}>
|
|
1435
1461
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
|
1436
1462
|
</button>
|
|
@@ -1467,7 +1493,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1467
1493
|
</div>
|
|
1468
1494
|
)}
|
|
1469
1495
|
|
|
1470
|
-
{postLike && (
|
|
1496
|
+
{postLike && kind !== 'event' && (
|
|
1471
1497
|
<label className="pe-field">
|
|
1472
1498
|
<span>Tags</span>
|
|
1473
1499
|
<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />
|
|
@@ -1561,19 +1587,6 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1561
1587
|
)}
|
|
1562
1588
|
</section>
|
|
1563
1589
|
|
|
1564
|
-
{kind === 'event' && (
|
|
1565
|
-
<EventPanel
|
|
1566
|
-
eventId={docId}
|
|
1567
|
-
meta={meta}
|
|
1568
|
-
onChange={(patch) => setMeta(patch)}
|
|
1569
|
-
calendars={calendars}
|
|
1570
|
-
fields={eventFields}
|
|
1571
|
-
siteTimezone={siteTimezone}
|
|
1572
|
-
title={meta.title}
|
|
1573
|
-
status={meta.status}
|
|
1574
|
-
/>
|
|
1575
|
-
)}
|
|
1576
|
-
|
|
1577
1590
|
{postLike && (
|
|
1578
1591
|
<section className="pe-sb-section">
|
|
1579
1592
|
<h3 className="pe-sb-title">Feature image & excerpt</h3>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useFrameAway } from './frameNav';
|
|
2
3
|
|
|
3
4
|
// Full-screen draft preview. Three views of the same draft:
|
|
4
5
|
// Page — live iframe of the public route at desktop / tablet / phone width
|
|
@@ -58,6 +59,8 @@ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startV
|
|
|
58
59
|
const [copied, setCopied] = useState(false);
|
|
59
60
|
const [shareOpen, setShareOpen] = useState(false);
|
|
60
61
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
62
|
+
const pvFrame = useRef<HTMLIFrameElement>(null);
|
|
63
|
+
const pvNav = useFrameAway(pvFrame, url);
|
|
61
64
|
const titleRef = useRef<HTMLInputElement>(null);
|
|
62
65
|
const descRef = useRef<HTMLTextAreaElement>(null);
|
|
63
66
|
const altRef = useRef<HTMLInputElement>(null);
|
|
@@ -203,6 +206,7 @@ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startV
|
|
|
203
206
|
</div>
|
|
204
207
|
)}
|
|
205
208
|
</div>
|
|
209
|
+
{view === 'page' && pvNav.away && <button type="button" className="pe-btn sm" onClick={pvNav.back} title={`Viewing ${pvNav.away}`}>← Back to page</button>}
|
|
206
210
|
<a className="pe-btn ghost sm pe-hide-sm" href={url} target="_blank" rel="noopener">Open ↗</a>
|
|
207
211
|
</div>
|
|
208
212
|
</div>
|
|
@@ -210,8 +214,10 @@ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startV
|
|
|
210
214
|
<div className={`pe-preview-stage view-${view}`}>
|
|
211
215
|
{view === 'page' && (
|
|
212
216
|
<iframe
|
|
213
|
-
|
|
217
|
+
ref={pvFrame}
|
|
218
|
+
key={`${refreshKey}-${pvNav.nav}`}
|
|
214
219
|
src={url}
|
|
220
|
+
onLoad={pvNav.check}
|
|
215
221
|
title="Preview"
|
|
216
222
|
style={{ width: device.width }}
|
|
217
223
|
className={device.key !== 'desktop' ? 'is-framed' : ''}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
import type { Place } from '../../../lib/schema';
|
|
4
|
+
import type { EventFieldConfig } from '../../../lib/calendar/config';
|
|
5
|
+
import type { MapsStatus } from '../../../lib/maps/types';
|
|
6
|
+
import { describeRepeat } from '../../../lib/calendar/rrule';
|
|
7
|
+
import RoomHolds from '../../calendar/RoomHolds';
|
|
8
|
+
import { eventWhenLabel, type CalendarOption, type EventMeta } from '../EventPanel';
|
|
9
|
+
import { Section } from './Section';
|
|
10
|
+
import { LocationField } from './LocationField';
|
|
11
|
+
import { OrganizeCard, StatusSection } from './OrganizeCard';
|
|
12
|
+
import { RegistrationCard } from './RegistrationCard';
|
|
13
|
+
import { WhenCard } from './WhenCard';
|
|
14
|
+
import { registrationMode } from './registration';
|
|
15
|
+
|
|
16
|
+
// Every event setting, on the page above the event's own content, as one
|
|
17
|
+
// settings column: When · Where · CTA · Organize · Status, with a
|
|
18
|
+
// sticky index. Wide panes put the index at the side; narrower panes turn it
|
|
19
|
+
// into a jump bar and stack each section's label over its controls
|
|
20
|
+
// (container queries on the editor pane, so Split mode counts).
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
meta: EventMeta;
|
|
24
|
+
onChange: (patch: Partial<EventMeta>) => void;
|
|
25
|
+
calendars: CalendarOption[];
|
|
26
|
+
fields: EventFieldConfig[];
|
|
27
|
+
siteTimezone: string;
|
|
28
|
+
maps: MapsStatus;
|
|
29
|
+
eventId: string;
|
|
30
|
+
title: string;
|
|
31
|
+
status: string;
|
|
32
|
+
tags?: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SECTIONS = [
|
|
36
|
+
{ id: 'ev-when', label: 'When' },
|
|
37
|
+
{ id: 'ev-where', label: 'Where' },
|
|
38
|
+
{ id: 'ev-registration', label: 'CTA' },
|
|
39
|
+
{ id: 'ev-organize', label: 'Organize' },
|
|
40
|
+
{ id: 'ev-status', label: 'Status' },
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
44
|
+
|
|
45
|
+
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags }: Props) {
|
|
46
|
+
const [places, setPlaces] = useState<Place[]>([]);
|
|
47
|
+
const [current, setCurrent] = useState<string>(SECTIONS[0].id);
|
|
48
|
+
const root = useRef<HTMLDivElement>(null);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let live = true;
|
|
51
|
+
actions.places.list().then((r) => { if (live && r.data) setPlaces(r.data.places); }).catch(() => {});
|
|
52
|
+
return () => { live = false; };
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
// the index follows the section nearest the top of the editor pane
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const el = root.current;
|
|
58
|
+
if (!el) return;
|
|
59
|
+
const scroller = el.closest('.pe-main');
|
|
60
|
+
// the current section is the last one whose top has passed a line 30%
|
|
61
|
+
// down the pane; observing every section re-checks as they move
|
|
62
|
+
const pick = () => {
|
|
63
|
+
const box = scroller?.getBoundingClientRect() ?? { top: 0, height: window.innerHeight };
|
|
64
|
+
const line = box.top + Math.min(box.height * 0.3, 240);
|
|
65
|
+
let id: string = SECTIONS[0].id;
|
|
66
|
+
for (const s of SECTIONS) { const n = document.getElementById(s.id); if (n && n.getBoundingClientRect().top <= line) id = s.id; }
|
|
67
|
+
setCurrent(id);
|
|
68
|
+
};
|
|
69
|
+
const io = new IntersectionObserver(pick, { root: scroller, threshold: [0, 0.25, 0.5, 0.75, 1] });
|
|
70
|
+
for (const s of SECTIONS) { const n = document.getElementById(s.id); if (n) io.observe(n); }
|
|
71
|
+
// tall sections can cross the line between thresholds
|
|
72
|
+
let raf = 0;
|
|
73
|
+
const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(pick); };
|
|
74
|
+
scroller?.addEventListener('scroll', onScroll, { passive: true });
|
|
75
|
+
return () => { io.disconnect(); cancelAnimationFrame(raf); scroller?.removeEventListener('scroll', onScroll); };
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const mode = registrationMode(meta);
|
|
79
|
+
const when = eventWhenLabel(meta);
|
|
80
|
+
const repeats = describeRepeat(meta.rrule, meta.start);
|
|
81
|
+
const regLabel = mode === 'rsvp' ? `Form${meta.rsvp.capacity ? ` · ${meta.rsvp.capacity}` : ''}` : mode === 'link' ? meta.cta.label || 'Link' : 'None';
|
|
82
|
+
const cals = calendars.filter((c) => meta.calendarIds.includes(c.id));
|
|
83
|
+
const values: Record<string, string> = {
|
|
84
|
+
'ev-when': when.split(' · ')[0],
|
|
85
|
+
'ev-where': meta.location.name || 'Not set',
|
|
86
|
+
'ev-registration': regLabel,
|
|
87
|
+
'ev-organize': cals.length ? `${cals[0].name}${cals.length > 1 ? ` +${cals.length - 1}` : ''}` : 'Main calendar',
|
|
88
|
+
'ev-status': meta.cancelled ? 'Cancelled' : 'Going ahead',
|
|
89
|
+
};
|
|
90
|
+
const summary = [when, repeats, meta.location.name, mode === 'rsvp' ? 'Built-in form' : mode === 'link' ? meta.cta.label || 'CTA link' : ''].filter(Boolean);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="pe-evs" aria-label="Event settings" role="group" ref={root}>
|
|
94
|
+
<nav className="pe-evs-index" aria-label="Event settings sections">
|
|
95
|
+
{SECTIONS.map((s) => (
|
|
96
|
+
<button key={s.id} type="button" className={current === s.id ? 'is-current' : ''} aria-current={current === s.id ? 'true' : undefined} onClick={() => jump(s.id)}>
|
|
97
|
+
<b>{s.label}</b>
|
|
98
|
+
<small className={s.id === 'ev-status' && meta.cancelled ? 'is-cancelled' : ''}>{values[s.id]}</small>
|
|
99
|
+
</button>
|
|
100
|
+
))}
|
|
101
|
+
<button type="button" className="pe-evs-index-page" onClick={() => jump('ev-page')}>Event page ↓</button>
|
|
102
|
+
</nav>
|
|
103
|
+
<div className="pe-evs-col">
|
|
104
|
+
<p className={`pe-evs-sum ${meta.cancelled ? 'is-cancelled' : ''}`}>
|
|
105
|
+
<b>{title || 'Untitled event'}</b>
|
|
106
|
+
<span>{meta.cancelled && <em>Cancelled · </em>}{summary.join(' · ')}</span>
|
|
107
|
+
</p>
|
|
108
|
+
<WhenCard meta={meta} onChange={onChange} siteTimezone={siteTimezone} eventId={eventId} />
|
|
109
|
+
<Section
|
|
110
|
+
id="ev-where"
|
|
111
|
+
title="Where"
|
|
112
|
+
help={<>Pick a saved place, paste a map link, or type an address. <a href="/admin/calendar/settings#places" target="_blank" rel="noopener">Manage places ↗</a></>}
|
|
113
|
+
>
|
|
114
|
+
<LocationField
|
|
115
|
+
location={meta.location}
|
|
116
|
+
onChange={(location) => onChange({ location })}
|
|
117
|
+
maps={maps}
|
|
118
|
+
places={places}
|
|
119
|
+
onPlaceSaved={(p) => setPlaces((list) => [...list.filter((x) => x.id !== p.id), p])}
|
|
120
|
+
/>
|
|
121
|
+
<RoomHolds
|
|
122
|
+
times={{ eventId, title, start: meta.start, end: meta.end, allDay: meta.allDay, timezone: meta.timezone, rrule: meta.rrule, exdates: meta.exdates }}
|
|
123
|
+
hold={meta.roomHold}
|
|
124
|
+
status={status}
|
|
125
|
+
locationName={meta.location.name}
|
|
126
|
+
onChange={(roomHold) => onChange({ roomHold })}
|
|
127
|
+
onLocation={(name) => onChange({ location: { ...meta.location, name, placeId: '' } })}
|
|
128
|
+
/>
|
|
129
|
+
</Section>
|
|
130
|
+
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} />
|
|
131
|
+
<OrganizeCard meta={meta} onChange={onChange} calendars={calendars} fields={fields} tags={tags} />
|
|
132
|
+
<StatusSection meta={meta} onChange={onChange} />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|