@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4
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 +105 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +17 -13
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +205 -174
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +11 -7
- package/src/layouts/Site.astro +136 -21
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +26 -14
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +14 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { addMonths, monthWeeks, type Iso } from '../../lib/calendar/grid';
|
|
2
|
+
|
|
3
|
+
// A small month picker that shows where things already are: one dot per
|
|
4
|
+
// calendar color on busy days (max 3), today in accent, the chosen day
|
|
5
|
+
// filled. Used by quick add, the New event dialog and the event editor.
|
|
6
|
+
interface Props {
|
|
7
|
+
month: string; // YYYY-MM
|
|
8
|
+
selected: Iso;
|
|
9
|
+
today: Iso;
|
|
10
|
+
weekStart: 0 | 1;
|
|
11
|
+
locale: string;
|
|
12
|
+
dots?: Record<Iso, string[]>;
|
|
13
|
+
onPick: (day: Iso) => void;
|
|
14
|
+
onMonth: (month: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const utc = (d: Iso) => new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10)));
|
|
18
|
+
|
|
19
|
+
export function weekdayLetters(locale: string, weekStart: 0 | 1, style: 'narrow' | 'short' = 'narrow'): string[] {
|
|
20
|
+
const f = new Intl.DateTimeFormat(locale, { weekday: style, timeZone: 'UTC' });
|
|
21
|
+
// 2023-01-01 was a Sunday
|
|
22
|
+
return Array.from({ length: 7 }, (_, i) => f.format(new Date(Date.UTC(2023, 0, 1 + ((i + weekStart) % 7)))));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function MiniMonth({ month, selected, today, weekStart, locale, dots = {}, onPick, onMonth }: Props) {
|
|
26
|
+
const weeks = monthWeeks(month, weekStart);
|
|
27
|
+
const title = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(utc(`${month}-01`));
|
|
28
|
+
const label = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC' });
|
|
29
|
+
return (
|
|
30
|
+
<div className="mini-month">
|
|
31
|
+
<div className="mini-month-head">
|
|
32
|
+
<b>{title}</b>
|
|
33
|
+
<span>
|
|
34
|
+
<button type="button" className="mini-nav" aria-label="Previous month" onClick={() => onMonth(addMonths(month, -1))}>‹</button>
|
|
35
|
+
<button type="button" className="mini-nav" aria-label="Next month" onClick={() => onMonth(addMonths(month, 1))}>›</button>
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="mini-month-grid" role="grid" aria-label={title}>
|
|
39
|
+
{weekdayLetters(locale, weekStart).map((d, i) => <span key={i} className="mini-dow" aria-hidden="true">{d}</span>)}
|
|
40
|
+
{weeks.flat().map((d) => {
|
|
41
|
+
const colors = dots[d] ?? [];
|
|
42
|
+
const cls = ['mini-day', d.slice(0, 7) !== month ? 'out' : '', d === today ? 'today' : '', d === selected ? 'sel' : ''].filter(Boolean).join(' ');
|
|
43
|
+
return (
|
|
44
|
+
<button key={d} type="button" className={cls} onClick={() => onPick(d)} aria-pressed={d === selected}
|
|
45
|
+
aria-label={`${label.format(utc(d))}${colors.length ? `, ${colors.length} event${colors.length === 1 ? '' : 's'}` : ''}`}>
|
|
46
|
+
{+d.slice(8)}
|
|
47
|
+
{colors.length > 0 && <span className="mini-dots">{colors.slice(0, 3).map((c, i) => <i key={i} style={{ background: c }} />)}</span>}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import QuickAdd, { type QuickCalendar } from './QuickAdd';
|
|
3
|
+
import '../../styles/schedule.css';
|
|
4
|
+
|
|
5
|
+
// "New event" anywhere outside the Schedule grid (the List view, empty
|
|
6
|
+
// states): any element with [data-new-event] — or ?new=1 in the URL — opens
|
|
7
|
+
// the quick-add dialog with its mini month, so you see where the event lands.
|
|
8
|
+
interface Props {
|
|
9
|
+
calendars: QuickCalendar[];
|
|
10
|
+
defaultCalendarId: string;
|
|
11
|
+
weekStart: 0 | 1;
|
|
12
|
+
locale: string;
|
|
13
|
+
today: string;
|
|
14
|
+
day: string; // suggested date
|
|
15
|
+
open?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function NewEventDialog(props: Props) {
|
|
19
|
+
const [open, setOpen] = useState(Boolean(props.open));
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const onClick = (e: MouseEvent) => {
|
|
22
|
+
if ((e.target as HTMLElement).closest('[data-new-event]')) { e.preventDefault(); setOpen(true); }
|
|
23
|
+
};
|
|
24
|
+
const onKey = (e: KeyboardEvent) => {
|
|
25
|
+
const t = e.target as HTMLElement;
|
|
26
|
+
if ((e.key === 'n' || e.key === 'N') && !e.metaKey && !e.ctrlKey && !t.closest('input, textarea, select, [contenteditable], dialog[open]')) { e.preventDefault(); setOpen(true); }
|
|
27
|
+
};
|
|
28
|
+
document.addEventListener('click', onClick);
|
|
29
|
+
addEventListener('keydown', onKey);
|
|
30
|
+
return () => { document.removeEventListener('click', onClick); removeEventListener('keydown', onKey); };
|
|
31
|
+
}, []);
|
|
32
|
+
if (!open) return null;
|
|
33
|
+
return (
|
|
34
|
+
<QuickAdd start={{ day: props.day }} mode="dialog" calendars={props.calendars} defaultCalendarId={props.defaultCalendarId}
|
|
35
|
+
weekStart={props.weekStart} locale={props.locale} today={props.today}
|
|
36
|
+
onClose={() => setOpen(false)}
|
|
37
|
+
onSaved={(_id, status) => {
|
|
38
|
+
const u = new URL(location.href);
|
|
39
|
+
u.searchParams.delete('new');
|
|
40
|
+
u.searchParams.set('notice', status === 'published' ? 'Event published.' : 'Draft saved — publish it when it’s ready.');
|
|
41
|
+
location.href = u.toString();
|
|
42
|
+
}} />
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
import { addDays, diffDays, monthOf, monthWeeks, type Iso } from '../../lib/calendar/grid';
|
|
4
|
+
import { buildRepeat, parseRepeat, weekdayOf, WEEKDAY_CODES } from '../../lib/calendar/rrule';
|
|
5
|
+
import type { BoardItem } from '../../lib/calendar/board';
|
|
6
|
+
import MiniMonth from './MiniMonth';
|
|
7
|
+
import { fetchRoomStatus, NO_HOLD } from './RoomHolds';
|
|
8
|
+
|
|
9
|
+
// Quick add: the small form behind "click a day" and "New event". As a
|
|
10
|
+
// popover it's pinned to the clicked day; as a dialog (New event, N key) it
|
|
11
|
+
// also shows a mini month with the busy days, plus what else is on the
|
|
12
|
+
// chosen day — so you can see where the event lands before saving.
|
|
13
|
+
|
|
14
|
+
export interface QuickCalendar { id: string; name: string; color: string }
|
|
15
|
+
|
|
16
|
+
export interface QuickStart {
|
|
17
|
+
day: Iso;
|
|
18
|
+
endDay?: Iso;
|
|
19
|
+
allDay?: boolean;
|
|
20
|
+
startMin?: number; // week view: the clicked half hour
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
start: QuickStart;
|
|
25
|
+
mode: 'popover' | 'dialog';
|
|
26
|
+
anchor?: DOMRect | null;
|
|
27
|
+
calendars: QuickCalendar[];
|
|
28
|
+
defaultCalendarId: string;
|
|
29
|
+
weekStart: 0 | 1;
|
|
30
|
+
locale: string;
|
|
31
|
+
today: Iso;
|
|
32
|
+
// board items (popover/dialog on the Schedule); fetched when absent
|
|
33
|
+
items?: BoardItem[];
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
onSaved: (id: string, status: string) => void;
|
|
36
|
+
// live preview on the grid while typing
|
|
37
|
+
onDraft?: (draft: { title: string; day: Iso; endDay: Iso; allDay: boolean; startMin: number; endMin: number; calendarId: string } | null) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
41
|
+
const toTime = (min: number) => `${pad(Math.floor(min / 60) % 24)}:${pad(min % 60)}`;
|
|
42
|
+
const fromTime = (t: string) => (/^\d{2}:\d{2}$/.test(t) ? +t.slice(0, 2) * 60 + +t.slice(3) : 0);
|
|
43
|
+
const utc = (d: Iso) => new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10)));
|
|
44
|
+
const ORD = ['', 'first', 'second', 'third', 'fourth', 'last'];
|
|
45
|
+
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
|
46
|
+
|
|
47
|
+
type RepeatKey = 'none' | 'daily' | 'weekly' | 'monthly-day' | 'monthly-weekday' | 'yearly';
|
|
48
|
+
|
|
49
|
+
function repeatRule(key: RepeatKey, start: Iso): string {
|
|
50
|
+
const base = parseRepeat('', start);
|
|
51
|
+
switch (key) {
|
|
52
|
+
case 'daily': return buildRepeat({ ...base, freq: 'DAILY' }, start);
|
|
53
|
+
case 'weekly': return buildRepeat({ ...base, freq: 'WEEKLY', byday: [WEEKDAY_CODES[weekdayOf(start).dow]] }, start);
|
|
54
|
+
case 'monthly-day': return buildRepeat({ ...base, freq: 'MONTHLY', monthly: 'day' }, start);
|
|
55
|
+
case 'monthly-weekday': return buildRepeat({ ...base, freq: 'MONTHLY', monthly: 'weekday' }, start);
|
|
56
|
+
case 'yearly': return buildRepeat({ ...base, freq: 'YEARLY' }, start);
|
|
57
|
+
default: return '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function QuickAdd(props: Props) {
|
|
62
|
+
const { start, mode, calendars, locale, weekStart, today, onClose, onSaved, onDraft } = props;
|
|
63
|
+
const timed = start.startMin !== undefined;
|
|
64
|
+
const [title, setTitle] = useState('');
|
|
65
|
+
const [allDay, setAllDay] = useState(start.allDay ?? (start.endDay !== undefined && start.endDay !== start.day));
|
|
66
|
+
const [day, setDay] = useState(start.day);
|
|
67
|
+
const [endDay, setEndDay] = useState(start.endDay ?? start.day);
|
|
68
|
+
const [from, setFrom] = useState(toTime(timed ? start.startMin! : 600));
|
|
69
|
+
const [to, setTo] = useState(toTime((timed ? start.startMin! : 600) + 60));
|
|
70
|
+
const [calendarId, setCalendarId] = useState(calendars.some((c) => c.id === props.defaultCalendarId) ? props.defaultCalendarId : '');
|
|
71
|
+
const [repeat, setRepeat] = useState<RepeatKey>('none');
|
|
72
|
+
const [place, setPlace] = useState('');
|
|
73
|
+
const [roomIds, setRoomIds] = useState<string[]>([]);
|
|
74
|
+
const [rooms, setRooms] = useState<{ id: string; name: string; busy: number }[]>([]);
|
|
75
|
+
const [busy, setBusy] = useState<'' | 'draft' | 'publish' | 'more'>('');
|
|
76
|
+
const [error, setError] = useState('');
|
|
77
|
+
const [miniMonth, setMiniMonth] = useState(monthOf(start.day));
|
|
78
|
+
const [fetched, setFetched] = useState<BoardItem[] | null>(null);
|
|
79
|
+
const box = useRef<HTMLFormElement>(null);
|
|
80
|
+
const titleRef = useRef<HTMLInputElement>(null);
|
|
81
|
+
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
82
|
+
|
|
83
|
+
// dialog outside the Schedule: load the mini month's events for the dots
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (mode !== 'dialog' || props.items) return;
|
|
86
|
+
const weeks = monthWeeks(miniMonth, weekStart);
|
|
87
|
+
let live = true;
|
|
88
|
+
actions.calendar.range({ from: weeks[0][0], to: weeks[weeks.length - 1][6] }).then((r) => { if (live && r.data) setFetched(r.data.items); });
|
|
89
|
+
return () => { live = false; };
|
|
90
|
+
}, [mode, miniMonth, weekStart, props.items]);
|
|
91
|
+
const items = props.items ?? fetched ?? [];
|
|
92
|
+
|
|
93
|
+
useEffect(() => { titleRef.current?.focus(); }, []);
|
|
94
|
+
|
|
95
|
+
// bookable rooms with free/booked status at the chosen time (none = row hidden)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const t = setTimeout(() => {
|
|
98
|
+
const s = allDay ? day : `${day}T${from}`;
|
|
99
|
+
const e = allDay ? (endDay < day ? day : endDay) : `${endDay < day ? day : endDay}T${to}`;
|
|
100
|
+
fetchRoomStatus({ eventId: '', title, start: s, end: e, allDay, timezone: '', rrule: repeatRule(repeat, day), exdates: [] }, NO_HOLD)
|
|
101
|
+
.then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length }))))
|
|
102
|
+
.catch(() => {});
|
|
103
|
+
}, 350);
|
|
104
|
+
return () => clearTimeout(t);
|
|
105
|
+
}, [day, endDay, from, to, allDay, repeat]);
|
|
106
|
+
|
|
107
|
+
// keep the end after the start when the start moves
|
|
108
|
+
const moveDay = (d: Iso) => { const len = diffDays(day, endDay); setDay(d); setEndDay(addDays(d, Math.max(0, len))); setMiniMonth(monthOf(d)); };
|
|
109
|
+
const moveFrom = (t: string) => { const len = fromTime(to) - fromTime(from); setFrom(t); setTo(toTime(Math.min(fromTime(t) + Math.max(15, len), 23 * 60 + 59))); };
|
|
110
|
+
|
|
111
|
+
const startMin = allDay ? 0 : fromTime(from);
|
|
112
|
+
const endMin = allDay ? 1440 : fromTime(to);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
onDraft?.({ title: title || 'New event', day, endDay: endDay < day ? day : endDay, allDay, startMin, endMin, calendarId });
|
|
115
|
+
}, [title, day, endDay, allDay, startMin, endMin, calendarId]);
|
|
116
|
+
useEffect(() => () => onDraft?.(null), []);
|
|
117
|
+
|
|
118
|
+
// popover placement: beside the clicked cell, clamped to the viewport
|
|
119
|
+
useLayoutEffect(() => {
|
|
120
|
+
if (mode !== 'popover' || !box.current) return;
|
|
121
|
+
if (window.matchMedia('(max-width: 640px)').matches) { setPos(null); return; }
|
|
122
|
+
const a = props.anchor;
|
|
123
|
+
const w = box.current.offsetWidth, h = box.current.offsetHeight;
|
|
124
|
+
if (!a) { setPos({ left: (innerWidth - w) / 2, top: Math.max(16, (innerHeight - h) / 2) }); return; }
|
|
125
|
+
let left = a.right + 8;
|
|
126
|
+
if (left + w > innerWidth - 12) left = a.left - w - 8;
|
|
127
|
+
if (left < 12) left = Math.min(innerWidth - w - 12, Math.max(12, a.left));
|
|
128
|
+
const top = Math.min(Math.max(12, a.top - 20), innerHeight - h - 12);
|
|
129
|
+
setPos({ left, top });
|
|
130
|
+
}, [mode, props.anchor]);
|
|
131
|
+
|
|
132
|
+
const dots = useMemo(() => {
|
|
133
|
+
const out: Record<Iso, string[]> = {};
|
|
134
|
+
for (const it of items) {
|
|
135
|
+
for (let d = it.startDay; d <= it.endDay; d = addDays(d, 1)) {
|
|
136
|
+
const list = (out[d] ??= []);
|
|
137
|
+
if (!list.includes(it.color)) list.push(it.color);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}, [items]);
|
|
142
|
+
const sameDay = items.filter((it) => it.startDay <= day && it.endDay >= day);
|
|
143
|
+
|
|
144
|
+
const fmtDay = (d: Iso) => new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(utc(d));
|
|
145
|
+
const wd = weekdayOf(day);
|
|
146
|
+
const weekdayName = new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(utc(day));
|
|
147
|
+
const monthDay = new Intl.DateTimeFormat(locale, { month: 'long', day: 'numeric', timeZone: 'UTC' }).format(utc(day));
|
|
148
|
+
const repeatOptions: [RepeatKey, string][] = [
|
|
149
|
+
['none', 'Doesn’t repeat'],
|
|
150
|
+
['daily', 'Every day'],
|
|
151
|
+
['weekly', `Weekly on ${weekdayName}`],
|
|
152
|
+
['monthly-day', `Monthly on day ${wd.day}`],
|
|
153
|
+
['monthly-weekday', `Monthly on the ${ORD[wd.nth >= 5 ? 5 : wd.nth]} ${weekdayName}`],
|
|
154
|
+
['yearly', `Yearly on ${monthDay}`],
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
async function save(kind: 'draft' | 'publish' | 'more') {
|
|
158
|
+
setBusy(kind);
|
|
159
|
+
setError('');
|
|
160
|
+
const s = allDay ? day : `${day}T${from}`;
|
|
161
|
+
const e = allDay ? (endDay < day ? day : endDay) : `${endDay < day ? day : endDay}T${to}`;
|
|
162
|
+
const r = await actions.events.quickCreate({
|
|
163
|
+
title: title.trim() || 'Untitled event', start: s, end: e, allDay, calendarId,
|
|
164
|
+
rrule: repeatRule(repeat, day), location: place || rooms.filter((r) => roomIds.includes(r.id)).map((r) => r.name).join(' & '), publish: kind === 'publish', roomIds,
|
|
165
|
+
});
|
|
166
|
+
if (r.error || !r.data) { setBusy(''); setError(r.error?.message ?? 'Couldn’t save the event.'); return; }
|
|
167
|
+
if (kind === 'more') { window.location.href = `/admin/calendar/events/${r.data.id}`; return; }
|
|
168
|
+
onSaved(r.data.id, r.data.status);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const submit = (e: FormEvent) => { e.preventDefault(); void save('draft'); };
|
|
172
|
+
const onKey = (e: KeyboardEvent) => {
|
|
173
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
|
174
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void save('publish'); }
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const form = (
|
|
178
|
+
<form ref={box} className={`qa qa-${mode}`} onSubmit={submit} onKeyDown={onKey}
|
|
179
|
+
style={mode === 'popover' && pos ? { left: pos.left, top: pos.top } : undefined} role="dialog" aria-label="New event">
|
|
180
|
+
<div className="qa-main">
|
|
181
|
+
<input ref={titleRef} className="qa-title" placeholder="Event title" value={title} maxLength={300} onChange={(e) => setTitle(e.target.value)} aria-label="Title" />
|
|
182
|
+
<div className="qa-row top">
|
|
183
|
+
<span className="qa-lab">When</span>
|
|
184
|
+
<div className="qa-when">
|
|
185
|
+
<div className="qa-line">
|
|
186
|
+
<input type="date" value={day} required onChange={(e) => e.target.value && moveDay(e.target.value)} aria-label="Start date" />
|
|
187
|
+
{(allDay || endDay !== day) && <><span className="qa-dash">–</span><input type="date" value={endDay} min={day} onChange={(e) => e.target.value && setEndDay(e.target.value)} aria-label="End date" /></>}
|
|
188
|
+
</div>
|
|
189
|
+
{!allDay && (
|
|
190
|
+
<div className="qa-line">
|
|
191
|
+
<input type="time" value={from} step={300} onChange={(e) => moveFrom(e.target.value)} aria-label="Start time" />
|
|
192
|
+
<span className="qa-dash">–</span>
|
|
193
|
+
<input type="time" value={to} step={300} onChange={(e) => setTo(e.target.value)} aria-label="End time" />
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
<label className="qa-check"><input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} /> All day</label>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
<div className="qa-row">
|
|
200
|
+
<span className="qa-lab">Calendar</span>
|
|
201
|
+
<span className="qa-cal">
|
|
202
|
+
<i style={{ background: calendars.find((c) => c.id === calendarId)?.color ?? 'transparent' }} className={calendarId ? '' : 'none'} />
|
|
203
|
+
<select value={calendarId} onChange={(e) => { if (e.target.value === '__new') { window.location.href = '/admin/calendar/settings#calendars'; return; } setCalendarId(e.target.value); }} aria-label="Calendar">
|
|
204
|
+
<option value="">No calendar</option>
|
|
205
|
+
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
206
|
+
<option value="__new">+ New calendar…</option>
|
|
207
|
+
</select>
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="qa-row">
|
|
211
|
+
<span className="qa-lab">Repeats</span>
|
|
212
|
+
<select value={repeat} onChange={(e) => setRepeat(e.target.value as RepeatKey)} aria-label="Repeats">
|
|
213
|
+
{repeatOptions.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
|
214
|
+
</select>
|
|
215
|
+
</div>
|
|
216
|
+
<div className="qa-row">
|
|
217
|
+
<span className="qa-lab">Where</span>
|
|
218
|
+
<input className="qa-grow" placeholder="Add a location" value={place} maxLength={300} onChange={(e) => setPlace(e.target.value)} aria-label="Location" />
|
|
219
|
+
</div>
|
|
220
|
+
{rooms.length > 0 && (
|
|
221
|
+
<div className="qa-row top">
|
|
222
|
+
<span className="qa-lab">Rooms</span>
|
|
223
|
+
<div className="qa-rooms">
|
|
224
|
+
{rooms.map((r) => (
|
|
225
|
+
<label key={r.id} className="qa-check">
|
|
226
|
+
<input type="checkbox" checked={roomIds.includes(r.id)} onChange={(e) => setRoomIds(e.target.checked ? [...roomIds, r.id] : roomIds.filter((x) => x !== r.id))} />
|
|
227
|
+
{r.name} <em className={r.busy ? 'busy' : 'free'}>{r.busy ? `${r.busy} booking${r.busy === 1 ? '' : 's'}` : 'free'}</em>
|
|
228
|
+
</label>
|
|
229
|
+
))}
|
|
230
|
+
{roomIds.some((id) => rooms.find((r) => r.id === id)?.busy) && <small className="qa-free">Already booked. Use More options to keep or cancel those bookings.</small>}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
{error && <p className="qa-error" role="alert">{error}</p>}
|
|
235
|
+
<div className="qa-actions">
|
|
236
|
+
<button type="button" className="btn ghost qa-more" disabled={!!busy} onClick={() => save('more')}>More options</button>
|
|
237
|
+
<button type="submit" className="btn" disabled={!!busy}>{busy === 'draft' ? 'Saving…' : 'Save draft'} <kbd>↵</kbd></button>
|
|
238
|
+
<button type="button" className="btn primary" disabled={!!busy} onClick={() => save('publish')} title={`${isMac ? '⌘' : 'Ctrl'} Enter`}>{busy === 'publish' ? 'Publishing…' : 'Publish'}</button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
{mode === 'dialog' && (
|
|
242
|
+
<aside className="qa-side">
|
|
243
|
+
<MiniMonth month={miniMonth} selected={day} today={today} weekStart={weekStart} locale={locale} dots={dots} onPick={moveDay} onMonth={setMiniMonth} />
|
|
244
|
+
<div className="qa-also">
|
|
245
|
+
<b>Also on {fmtDay(day)}</b>
|
|
246
|
+
{sameDay.length === 0 ? <span className="qa-free">Nothing scheduled.</span> : (
|
|
247
|
+
<ul>
|
|
248
|
+
{sameDay.slice(0, 5).map((it) => <li key={it.key}><i style={{ background: it.color }} />{it.allDay ? 'All day' : it.range} · {it.title}</li>)}
|
|
249
|
+
{sameDay.length > 5 && <li className="qa-free">+{sameDay.length - 5} more</li>}
|
|
250
|
+
</ul>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</aside>
|
|
254
|
+
)}
|
|
255
|
+
</form>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className={`qa-layer qa-layer-${mode}`} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
260
|
+
{form}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
|
|
4
|
+
// Event editor → Rooms: tick the bookable rooms/people this event uses and
|
|
5
|
+
// they can't be booked while it happens (± setup/cleanup) or all day. Each
|
|
6
|
+
// room shows whether it's free at the event's time; overlapping bookings show
|
|
7
|
+
// right here, with Keep (share the room) or Cancel and email. The same check
|
|
8
|
+
// runs again when you publish (RoomConflictDialog).
|
|
9
|
+
|
|
10
|
+
export interface RoomHoldValue {
|
|
11
|
+
resourceIds: string[];
|
|
12
|
+
span: 'event' | 'day';
|
|
13
|
+
before: number;
|
|
14
|
+
after: number;
|
|
15
|
+
whileDraft: boolean;
|
|
16
|
+
keep: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RoomTimes {
|
|
20
|
+
eventId: string;
|
|
21
|
+
title: string;
|
|
22
|
+
start: string;
|
|
23
|
+
end: string;
|
|
24
|
+
allDay: boolean;
|
|
25
|
+
timezone: string;
|
|
26
|
+
rrule: string;
|
|
27
|
+
exdates: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Conflict { id: string; name: string; when: string; status: string; block: boolean }
|
|
31
|
+
interface RoomRow { id: string; name: string; kind: string; capacity: number | null; conflicts: Conflict[]; datesWithBookings: number; dates: number }
|
|
32
|
+
interface Status { rooms: RoomRow[]; holds: string }
|
|
33
|
+
|
|
34
|
+
export const NO_HOLD: RoomHoldValue = { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] };
|
|
35
|
+
|
|
36
|
+
export async function fetchRoomStatus(times: RoomTimes, hold: RoomHoldValue): Promise<Status | null> {
|
|
37
|
+
if (!times.start) return null;
|
|
38
|
+
const r = await actions.events.roomStatus({ ...times, roomHold: hold });
|
|
39
|
+
return r.data ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Props {
|
|
43
|
+
times: RoomTimes;
|
|
44
|
+
hold: RoomHoldValue;
|
|
45
|
+
status: string; // event status
|
|
46
|
+
locationName: string;
|
|
47
|
+
onChange: (hold: RoomHoldValue) => void;
|
|
48
|
+
onLocation: (name: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function RoomHolds({ times, hold, status, locationName, onChange, onLocation }: Props) {
|
|
52
|
+
const [data, setData] = useState<Status | null>(null);
|
|
53
|
+
const [busy, setBusy] = useState(false);
|
|
54
|
+
const [cancelFor, setCancelFor] = useState<string[] | null>(null);
|
|
55
|
+
const [message, setMessage] = useState('');
|
|
56
|
+
const [note, setNote] = useState('');
|
|
57
|
+
const seq = useRef(0);
|
|
58
|
+
const key = JSON.stringify([times, hold]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const n = ++seq.current;
|
|
62
|
+
const t = setTimeout(() => { fetchRoomStatus(times, hold).then((d) => { if (n === seq.current) setData(d); }).catch(() => {}); }, 300);
|
|
63
|
+
return () => clearTimeout(t);
|
|
64
|
+
}, [key]);
|
|
65
|
+
|
|
66
|
+
// no bookable rooms (or bookings off): the section stays out of the way
|
|
67
|
+
if (!data || data.rooms.length === 0) return null;
|
|
68
|
+
|
|
69
|
+
const selected = new Set(hold.resourceIds);
|
|
70
|
+
const toggle = (room: RoomRow) => {
|
|
71
|
+
const ids = selected.has(room.id) ? hold.resourceIds.filter((x) => x !== room.id) : [...hold.resourceIds, room.id];
|
|
72
|
+
onChange({ ...hold, resourceIds: ids });
|
|
73
|
+
// fill the place when it's empty, or still exactly what the rooms filled
|
|
74
|
+
// in before — never overwrite something typed by hand
|
|
75
|
+
const names = (list: string[]) => data.rooms.filter((r) => list.includes(r.id)).map((r) => r.name).join(' & ');
|
|
76
|
+
const auto = !locationName.trim() || locationName === names(hold.resourceIds);
|
|
77
|
+
if (auto) onLocation(names(ids));
|
|
78
|
+
};
|
|
79
|
+
const label = (r: RoomRow) => {
|
|
80
|
+
if (!r.conflicts.length) return { text: 'Free', cls: 'free' };
|
|
81
|
+
if (r.dates > 1) return { text: `Booked on ${r.datesWithBookings} of ${r.dates} dates`, cls: 'busy' };
|
|
82
|
+
return { text: `${r.conflicts.length} booking${r.conflicts.length === 1 ? '' : 's'}`, cls: 'busy' };
|
|
83
|
+
};
|
|
84
|
+
const conflicted = data.rooms.filter((r) => selected.has(r.id) && r.conflicts.length);
|
|
85
|
+
const draft = status === 'draft';
|
|
86
|
+
|
|
87
|
+
async function cancelBookings(ids: string[]) {
|
|
88
|
+
setBusy(true);
|
|
89
|
+
const r = await actions.events.cancelBookingsForEvent({ ids, message });
|
|
90
|
+
setBusy(false);
|
|
91
|
+
setCancelFor(null);
|
|
92
|
+
setMessage('');
|
|
93
|
+
setNote(r.error ? r.error.message : `Cancelled ${r.data?.cancelled ?? 0} booking${r.data?.cancelled === 1 ? '' : 's'} and emailed the guests.`);
|
|
94
|
+
setData(await fetchRoomStatus(times, hold));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<section className="pe-sb-section pe-ev rh">
|
|
99
|
+
<h3 className="pe-sb-title">Rooms</h3>
|
|
100
|
+
<p className="pe-ev-note">Rooms and people this event uses can't be booked while it happens.</p>
|
|
101
|
+
<div className="rh-list">
|
|
102
|
+
{data.rooms.map((r) => {
|
|
103
|
+
const l = label(r);
|
|
104
|
+
return (
|
|
105
|
+
<label key={r.id} className={`rh-room${selected.has(r.id) ? ' on' : ''}`}>
|
|
106
|
+
<input type="checkbox" checked={selected.has(r.id)} onChange={() => toggle(r)} />
|
|
107
|
+
<span className="rh-name"><b>{r.name}</b><small>{r.kind}{r.capacity ? ` · up to ${r.capacity}` : ''}</small></span>
|
|
108
|
+
<span className={`rh-st ${l.cls}`}>{l.text}</span>
|
|
109
|
+
</label>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{hold.resourceIds.length > 0 && (
|
|
115
|
+
<>
|
|
116
|
+
<div className="rh-span" role="radiogroup" aria-label="How long to hold">
|
|
117
|
+
<button type="button" aria-pressed={hold.span === 'event'} onClick={() => onChange({ ...hold, span: 'event' })}>During the event</button>
|
|
118
|
+
<button type="button" aria-pressed={hold.span === 'day'} onClick={() => onChange({ ...hold, span: 'day' })}>Whole day</button>
|
|
119
|
+
</div>
|
|
120
|
+
{hold.span === 'event' && (
|
|
121
|
+
<div className="rh-buffers">
|
|
122
|
+
<label>Setup <input type="number" min={0} max={1440} step={15} value={hold.before} onChange={(e) => onChange({ ...hold, before: Math.max(0, Math.min(1440, Number(e.target.value) || 0)) })} /> min before</label>
|
|
123
|
+
<label>Cleanup <input type="number" min={0} max={1440} step={15} value={hold.after} onChange={(e) => onChange({ ...hold, after: Math.max(0, Math.min(1440, Number(e.target.value) || 0)) })} /> min after</label>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
{draft && (
|
|
127
|
+
<label className="pe-switch-row">
|
|
128
|
+
<span><b>Hold now</b><small>{hold.whileDraft ? 'Held while this is a draft, so nobody books it while you plan.' : 'Not held yet: rooms are held once you publish or schedule.'}</small></span>
|
|
129
|
+
<input type="checkbox" role="switch" className="pe-switch" checked={hold.whileDraft} onChange={(e) => onChange({ ...hold, whileDraft: e.target.checked })} />
|
|
130
|
+
</label>
|
|
131
|
+
)}
|
|
132
|
+
{conflicted.map((r) => (
|
|
133
|
+
<div key={r.id} className="rh-conflict" role="status">
|
|
134
|
+
<b>{r.name} already has {r.conflicts.length === 1 ? 'a booking' : `${r.conflicts.length} bookings`}</b>
|
|
135
|
+
{r.conflicts.slice(0, 5).map((c) => (
|
|
136
|
+
<div key={c.id} className="rh-bk"><span>{c.name}</span><small>{c.when}{c.status === 'pending' ? ' · needs approval' : ''}</small></div>
|
|
137
|
+
))}
|
|
138
|
+
{r.conflicts.length > 5 && <small className="rh-more">+{r.conflicts.length - 5} more</small>}
|
|
139
|
+
{cancelFor?.join() === r.conflicts.map((c) => c.id).join() ? (
|
|
140
|
+
<div className="rh-cancel">
|
|
141
|
+
<textarea rows={3} placeholder="Message to the guests (optional): we need the hall for the Fall Festival — sorry! Please pick another time." value={message} onChange={(e) => setMessage(e.target.value)} />
|
|
142
|
+
<div className="rh-acts">
|
|
143
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => setCancelFor(null)}>Back</button>
|
|
144
|
+
<button type="button" className="pe-btn sm danger" disabled={busy} onClick={() => cancelBookings(r.conflicts.map((c) => c.id))}>{busy ? 'Cancelling…' : 'Cancel and email'}</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
) : (
|
|
148
|
+
<div className="rh-acts">
|
|
149
|
+
<button type="button" className="pe-btn sm" onClick={() => onChange({ ...hold, keep: [...new Set([...hold.keep, ...r.conflicts.map((c) => c.id)])] })}>Keep, share the room</button>
|
|
150
|
+
<button type="button" className="pe-btn sm" onClick={() => setCancelFor(r.conflicts.map((c) => c.id))}>Cancel and email…</button>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
))}
|
|
155
|
+
{note && <p className="pe-ev-note rh-done">{note}</p>}
|
|
156
|
+
{data.holds && <p className="pe-ev-note">{data.holds}{draft && !hold.whileDraft ? ' once published.' : '.'}</p>}
|
|
157
|
+
</>
|
|
158
|
+
)}
|
|
159
|
+
</section>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Publish-time check: overlapping bookings for the rooms this event holds. */
|
|
164
|
+
export function RoomConflictDialog({ rooms, onCancel, onConfirm }: {
|
|
165
|
+
rooms: RoomRow[];
|
|
166
|
+
onCancel: () => void;
|
|
167
|
+
onConfirm: (choice: { keep: string[]; cancel: string[]; message: string }) => Promise<void>;
|
|
168
|
+
}) {
|
|
169
|
+
const all = rooms.flatMap((r) => r.conflicts.map((c) => ({ ...c, room: r.name })));
|
|
170
|
+
const [choice, setChoice] = useState<Record<string, 'keep' | 'cancel'>>(() => Object.fromEntries(all.map((c) => [c.id, 'keep'])));
|
|
171
|
+
const [message, setMessage] = useState('');
|
|
172
|
+
const [busy, setBusy] = useState(false);
|
|
173
|
+
const dates = rooms.find((r) => r.dates > 1);
|
|
174
|
+
const cancelling = Object.values(choice).some((v) => v === 'cancel');
|
|
175
|
+
return (
|
|
176
|
+
<div className="rh-layer" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
177
|
+
<div className="rh-dialog" role="alertdialog" aria-label="Rooms already booked">
|
|
178
|
+
<b className="rh-title">Some rooms are already booked</b>
|
|
179
|
+
<p className="rh-sub">{all.length} booking{all.length === 1 ? '' : 's'} overlap{all.length === 1 ? 's' : ''} this event{dates ? ` (on ${rooms.reduce((n, r) => n + r.datesWithBookings, 0)} of ${dates.dates} dates)` : ''}. Choose what happens to each.</p>
|
|
180
|
+
<div className="rh-rows">
|
|
181
|
+
{all.map((c) => (
|
|
182
|
+
<div key={c.id} className="rh-row">
|
|
183
|
+
<span><b>{c.name} · {c.room}</b><small>{c.when}</small></span>
|
|
184
|
+
<select value={choice[c.id]} onChange={(e) => setChoice({ ...choice, [c.id]: e.target.value as 'keep' | 'cancel' })} aria-label={`What happens to ${c.name}`}>
|
|
185
|
+
<option value="keep">Keep (share the room)</option>
|
|
186
|
+
<option value="cancel">{c.block ? 'Remove the block' : 'Cancel and email'}</option>
|
|
187
|
+
</select>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
{cancelling && <textarea rows={3} className="rh-msg" placeholder="Message to the guests (optional)" value={message} onChange={(e) => setMessage(e.target.value)} />}
|
|
192
|
+
<div className="rh-acts end">
|
|
193
|
+
<button type="button" className="pe-btn ghost" onClick={onCancel}>Back to editing</button>
|
|
194
|
+
<button type="button" className="pe-btn primary" disabled={busy} onClick={async () => {
|
|
195
|
+
setBusy(true);
|
|
196
|
+
await onConfirm({ keep: all.filter((c) => choice[c.id] === 'keep').map((c) => c.id), cancel: all.filter((c) => choice[c.id] === 'cancel').map((c) => c.id), message });
|
|
197
|
+
setBusy(false);
|
|
198
|
+
}}>{busy ? 'Working…' : 'Continue'}</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
import { addDays, monthOf, monthWeeks, type Iso } from '../../lib/calendar/grid';
|
|
4
|
+
import type { BoardItem } from '../../lib/calendar/board';
|
|
5
|
+
import MiniMonth from './MiniMonth';
|
|
6
|
+
import '../../styles/schedule.css';
|
|
7
|
+
|
|
8
|
+
// Event editor → When: a mini month with the busy days marked, so you can
|
|
9
|
+
// see where this event lands next to everything else. Picking a day moves
|
|
10
|
+
// the event's date (the time stays).
|
|
11
|
+
interface Props {
|
|
12
|
+
date: Iso; // the event's start day
|
|
13
|
+
eventId: string;
|
|
14
|
+
onPick: (day: Iso) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function WhereItLands({ date, eventId, onPick }: Props) {
|
|
18
|
+
const [month, setMonth] = useState(monthOf(date));
|
|
19
|
+
const [data, setData] = useState<{ items: BoardItem[]; weekStart: 0 | 1; today: Iso } | null>(null);
|
|
20
|
+
const locale = typeof document !== 'undefined' ? document.documentElement.lang || 'en' : 'en';
|
|
21
|
+
|
|
22
|
+
useEffect(() => { setMonth(monthOf(date)); }, [date]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let live = true;
|
|
25
|
+
// a Sunday-first grid covers both week starts' first and last rows
|
|
26
|
+
const weeks = monthWeeks(month, 0);
|
|
27
|
+
actions.calendar.range({ from: addDays(weeks[0][0], -1), to: addDays(weeks[weeks.length - 1][6], 1) }).then((r) => {
|
|
28
|
+
if (live && r.data) setData({ items: r.data.items, weekStart: r.data.weekStart, today: r.data.today });
|
|
29
|
+
});
|
|
30
|
+
return () => { live = false; };
|
|
31
|
+
}, [month]);
|
|
32
|
+
|
|
33
|
+
const others = (data?.items ?? []).filter((it) => it.id !== eventId);
|
|
34
|
+
const dots: Record<Iso, string[]> = {};
|
|
35
|
+
for (const it of others) {
|
|
36
|
+
for (let d = it.startDay; d <= it.endDay; d = addDays(d, 1)) {
|
|
37
|
+
const list = (dots[d] ??= []);
|
|
38
|
+
if (!list.includes(it.color)) list.push(it.color);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const same = others.filter((it) => it.startDay <= date && it.endDay >= date);
|
|
42
|
+
return (
|
|
43
|
+
<div className="lands">
|
|
44
|
+
<MiniMonth month={month} selected={date} today={data?.today ?? date} weekStart={data?.weekStart ?? 0} locale={locale}
|
|
45
|
+
dots={dots} onPick={onPick} onMonth={setMonth} />
|
|
46
|
+
<p className="lands-also">
|
|
47
|
+
{same.length === 0 ? 'Nothing else on this day.' : <>Also this day: {same.slice(0, 3).map((it) => `${it.allDay ? '' : `${it.time} `}${it.title}`).join(' · ')}{same.length > 3 ? ` +${same.length - 3}` : ''}</>}
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|