@aglyn/plugins-events-calendar 1.0.0-beta.143

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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/events-calendar/src/lib/components/event-list.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { mdiCalendarStar } from '@aglyn/shared-data-mdi'\nimport Box from '@mui/material/Box'\nimport CircularProgress from '@mui/material/CircularProgress'\nimport Stack from '@mui/material/Stack'\nimport Typography from '@mui/material/Typography'\nimport { forwardRef, useEffect, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { generatePresetId } from '../utils/generate-preset-id'\n\n// Component ids are persisted in screen documents; never rename.\nexport const ID: Aglyn.ComponentId = 'eventList'\n\nexport interface EventListProps {\n /** 'upcoming' (default) or 'past'. */\n mode?: 'upcoming' | 'past'\n /** Heading above the list; empty hides it. */\n heading?: string\n /** Max events shown (default 10). */\n maxItems?: number\n}\n\nexport interface EventItem {\n $id: string\n title: string\n startsAtMs: number\n endsAtMs: number\n location?: string | null\n organizer?: string | null\n description?: string | null\n coverImage?: string | null\n /**\n * The author's description of the cover (AGL-2418). Absent or blank\n * renders `alt=\"\"` — correct for this layout, where the thumbnail sits\n * immediately beside the title that already names it.\n */\n coverImageAlt?: string | null\n}\n\n/**\n * One event as its `schema.org/Event` JSON-LD string (AGL-143/145).\n *\n * Lifted out of the JSX so the structured data can be asserted on its own,\n * which is what the surface is FOR — it is markup no human ever sees, so a\n * render test that only checks the visible list proves nothing about it.\n */\nexport function eventJsonLd(event: EventItem): string {\n // The cover, and only if a crawler could actually fetch it (AGL-1351).\n //\n // Calling the shared resolver with NO host is how this surface says \"accept\n // what is already absolute, and nothing else\": with no origin to resolve\n // against it returns undefined for a site-relative path and for a `media:`\n // reference alike, so neither can reach the markup. The origin lives on the\n // host record, which only the server holds — `/api/events/list` resolves\n // each cover there, so in practice an already-absolute URL passes straight\n // through and this stands as the guarantee that the node is never emitted\n // with an address a crawler cannot follow, whatever the payload contains.\n //\n // Deliberately NOT `window.location.origin`: on the console's Preview\n // surface that is the console, and the markup would then name this site's\n // image on a URL belonging to a different one.\n const image = Aglyn.resolveSocialImage({\n sources: [{ image: event.coverImage }],\n host: undefined,\n })\n return Aglyn.safeJsonLd({\n '@context': 'https://schema.org',\n '@type': 'Event',\n name: event.title,\n startDate: new Date(event.startsAtMs).toISOString(),\n ...(event.endsAtMs && {\n endDate: new Date(event.endsAtMs).toISOString(),\n }),\n ...(event.location && {\n location: { '@type': 'Place', name: event.location },\n }),\n ...(event.organizer && {\n organizer: {\n '@type': 'Organization',\n name: event.organizer,\n },\n }),\n ...(event.description && {\n description: event.description,\n }),\n // Absent rather than `\"image\": [null]` — `strictNullChecks` is off\n // repo-wide, so this guard is what keeps an unresolvable cover out.\n ...(image && { image: [image.url] }),\n })\n}\n\n/**\n * Event List (AGL-145, Event Calendar add-on): published events from the\n * public tenant API with schema.org Event JSON-LD per item (ties into the\n * AGL-143 structured-data work). Editor placeholder without a site\n * context; empty add-on hosts render nothing live.\n */\nconst EventList = forwardRef<HTMLDivElement, EventListProps>((props, ref) => {\n const { mode, heading, maxItems, ...rest } = props\n // Node styles ride the renderer-merged sx; recompose (stack.ts pattern).\n const nodeSx = Array.isArray(props['sx']) ? props['sx'] : [props['sx']]\n const { hostId } = Aglyn.useSite()\n const [events, setEvents] = useState<EventItem[] | null>(null)\n\n useEffect(() => {\n if (!hostId) return\n let active = true\n void fetch(\n `/api/events/list?hostId=${encodeURIComponent(hostId)}` +\n `&mode=${mode === 'past' ? 'past' : 'upcoming'}`,\n )\n .then((response) => response.json())\n .then((payload) => {\n if (active) setEvents(payload?.events ?? [])\n })\n .catch(() => {\n if (active) setEvents([])\n })\n return () => {\n active = false\n }\n }, [hostId, mode])\n\n if (!hostId) {\n return (\n <Box\n ref={ref}\n {...rest}\n sx={[\n {\n p: 3,\n border: '1px dashed',\n borderColor: 'divider',\n borderRadius: 1,\n color: 'text.secondary',\n fontSize: 13,\n fontFamily: 'system-ui, sans-serif',\n },\n ...nodeSx,\n ]}\n >\n {'Event list — published events render here (Event Calendar add-on)'}\n </Box>\n )\n }\n if (events === null) {\n return (\n <Box ref={ref} {...rest} sx={[{ p: 2 }, ...nodeSx]}>\n <CircularProgress size={24} />\n </Box>\n )\n }\n const visible = events.slice(0, maxItems && maxItems > 0 ? maxItems : 10)\n if (!visible.length) return <Box ref={ref} {...rest} />\n\n return (\n <Stack ref={ref} spacing={2} {...rest}>\n {heading ? <Typography variant=\"h5\">{heading}</Typography> : null}\n {visible.map((event) => (\n <Stack\n key={event.$id}\n direction=\"row\"\n spacing={2}\n sx={{ alignItems: 'flex-start' }}\n >\n {/* The cover is a free-text console field rendered verbatim — one\n of AGL-1725's raw author sinks. Scheme rule only, never a host\n check: `http:` (an insecure egress no browser defends) and\n unknown schemes render nothing; https/relative/data render as\n stored. */}\n {event.coverImage &&\n !Aglyn.isRefusedAuthorImageSrc(event.coverImage) ? (\n <Box\n component=\"img\"\n src={event.coverImage}\n // The author's own description when they wrote one (AGL-2418),\n // and `alt=\"\"` otherwise. No fallback to the title: this is a\n // 96×96 thumbnail sitting immediately beside that title, so\n // borrowing it would announce the same words twice.\n alt={Aglyn.renderedMediaAlt(event.coverImageAlt)}\n sx={{\n width: 96,\n height: 96,\n objectFit: 'cover',\n borderRadius: 1,\n flexShrink: 0,\n }}\n // Deferred (AGL-2486). A 96x96 thumbnail, one per event, in a\n // list that is as long as the calendar is busy — previously\n // unhinted, so a twenty-event list opened twenty eager\n // default-priority fetches.\n {...Aglyn.DEFERRED_IMAGE_ATTRIBUTES}\n />\n ) : null}\n <Stack spacing={0.25} sx={{ minWidth: 0 }}>\n <Typography variant=\"h6\">{event.title}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {new Date(event.startsAtMs).toLocaleString([], {\n dateStyle: 'medium',\n timeStyle: 'short',\n })}\n {event.location ? ` · ${event.location}` : ''}\n {event.organizer ? ` · ${event.organizer}` : ''}\n </Typography>\n {event.description ? (\n <Typography variant=\"body2\">{event.description}</Typography>\n ) : null}\n </Stack>\n {/* schema.org Event (AGL-143/145), built above. */}\n <script\n type=\"application/ld+json\"\n dangerouslySetInnerHTML={{ __html: eventJsonLd(event) }}\n />\n </Stack>\n ))}\n </Stack>\n )\n})\nEventList.displayName = 'EventList'\n\nexport const schema: Aglyn.ComponentSchema<EventListProps> = {\n $id: ID,\n pluginId: BUNDLE_ID,\n displayName: 'Event List',\n description:\n 'Your published events, each marked up so search engines can show it.',\n category: Aglyn.ComponentCategory.DATA_DISPLAY,\n icon: {\n path: mdiCalendarStar.path,\n sx: { color: '#7b1fa2' },\n },\n flags: {\n selfClosing: Aglyn.FEATURE_FLAG.ENABLED,\n },\n attributes: [\n {\n name: 'heading',\n description: 'Heading above the list; empty hides it.',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Heading',\n },\n {\n name: 'mode',\n description: 'Which events to show.',\n component: Aglyn.FieldComponentType.SELECT,\n label: 'Show',\n options: [\n { value: '', label: 'Upcoming (default)' },\n { value: 'past', label: 'Past events' },\n ],\n },\n {\n name: 'maxItems',\n description: 'Maximum events shown (default 10).',\n component: Aglyn.FieldComponentType.TEXT_FIELD,\n label: 'Max items',\n type: 'number',\n },\n ],\n}\n\nexport const presets: Aglyn.PresetSchema[] = [\n {\n $id: generatePresetId(ID),\n type: Aglyn.NodeType.PRESET,\n displayName: 'Event List',\n pluginId: BUNDLE_ID,\n description: 'Published events with SEO Event markup (add-on)',\n category: Aglyn.ComponentCategory.DATA_DISPLAY,\n icon: {\n path: mdiCalendarStar.path,\n sx: { color: '#7b1fa2' },\n },\n data: {\n $id: null,\n componentId: ID,\n pluginId: BUNDLE_ID,\n props: {},\n },\n },\n]\n\nexport default EventList\n"],"names":["Aglyn","mdiCalendarStar","Box","CircularProgress","Stack","Typography","forwardRef","useEffect","useState","BUNDLE_ID","generatePresetId","ID","eventJsonLd","event","image","resolveSocialImage","sources","coverImage","host","undefined","safeJsonLd","name","title","startDate","Date","startsAtMs","toISOString","endsAtMs","endDate","location","organizer","description","url","EventList","props","ref","mode","heading","maxItems","rest","nodeSx","Array","isArray","hostId","useSite","events","setEvents","active","fetch","encodeURIComponent","then","response","json","payload","catch","sx","p","border","borderColor","borderRadius","color","fontSize","fontFamily","size","visible","slice","length","spacing","variant","map","direction","alignItems","isRefusedAuthorImageSrc","component","src","alt","renderedMediaAlt","coverImageAlt","width","height","objectFit","flexShrink","DEFERRED_IMAGE_ATTRIBUTES","minWidth","toLocaleString","dateStyle","timeStyle","script","type","dangerouslySetInnerHTML","__html","$id","displayName","schema","pluginId","category","ComponentCategory","DATA_DISPLAY","icon","path","flags","selfClosing","FEATURE_FLAG","ENABLED","attributes","FieldComponentType","TEXT_FIELD","label","SELECT","options","value","presets","NodeType","PRESET","data","componentId"],"mappings":";;;AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,eAAe,QAAQ,yBAAwB;AACxD,OAAOC,SAAS,oBAAmB;AACnC,OAAOC,sBAAsB,iCAAgC;AAC7D,OAAOC,WAAW,sBAAqB;AACvC,OAAOC,gBAAgB,2BAA0B;AACjD,SAASC,UAAU,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AACvD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAASC,gBAAgB,QAAQ,iCAA6B;AAE9D,iEAAiE;AACjE,OAAO,MAAMC,KAAwB,YAAW;AA4BhD;;;;;;CAMC,GACD,OAAO,SAASC,YAAYC,KAAgB;IAC1C,uEAAuE;IACvE,EAAE;IACF,4EAA4E;IAC5E,yEAAyE;IACzE,2EAA2E;IAC3E,4EAA4E;IAC5E,yEAAyE;IACzE,2EAA2E;IAC3E,0EAA0E;IAC1E,0EAA0E;IAC1E,EAAE;IACF,sEAAsE;IACtE,0EAA0E;IAC1E,+CAA+C;IAC/C,MAAMC,QAAQd,MAAMe,kBAAkB,CAAC;QACrCC,SAAS;YAAC;gBAAEF,OAAOD,MAAMI,UAAU;YAAC;SAAE;QACtCC,MAAMC;IACR;IACA,OAAOnB,MAAMoB,UAAU,CAAC;QACtB,YAAY;QACZ,SAAS;QACTC,MAAMR,MAAMS,KAAK;QACjBC,WAAW,IAAIC,KAAKX,MAAMY,UAAU,EAAEC,WAAW;OAC7Cb,MAAMc,QAAQ,IAAI;QACpBC,SAAS,IAAIJ,KAAKX,MAAMc,QAAQ,EAAED,WAAW;IAC/C,GACIb,MAAMgB,QAAQ,IAAI;QACpBA,UAAU;YAAE,SAAS;YAASR,MAAMR,MAAMgB,QAAQ;QAAC;IACrD,GACIhB,MAAMiB,SAAS,IAAI;QACrBA,WAAW;YACT,SAAS;YACTT,MAAMR,MAAMiB,SAAS;QACvB;IACF,GACIjB,MAAMkB,WAAW,IAAI;QACvBA,aAAalB,MAAMkB,WAAW;IAChC,GAGIjB,SAAS;QAAEA,OAAO;YAACA,MAAMkB,GAAG;SAAC;IAAC;AAEtC;AAEA;;;;;CAKC,GACD,MAAMC,0BAAY3B,WAA2C,CAAC4B,OAAOC;IACnE,MAAM,EAAEC,IAAI,EAAEC,OAAO,EAAEC,QAAQ,EAAW,GAAGJ,OAATK,wCAASL;;;;;IAC7C,yEAAyE;IACzE,MAAMM,SAASC,MAAMC,OAAO,CAACR,KAAK,CAAC,KAAK,IAAIA,KAAK,CAAC,KAAK,GAAG;QAACA,KAAK,CAAC,KAAK;KAAC;IACvE,MAAM,EAAES,MAAM,EAAE,GAAG3C,MAAM4C,OAAO;IAChC,MAAM,CAACC,QAAQC,UAAU,GAAGtC,SAA6B;IAEzDD,UAAU;QACR,IAAI,CAACoC,QAAQ;QACb,IAAII,SAAS;QACb,KAAKC,MACH,CAAC,wBAAwB,EAAEC,mBAAmBN,SAAS,GACrD,CAAC,MAAM,EAAEP,SAAS,SAAS,SAAS,YAAY,EAEjDc,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,IAChCF,IAAI,CAAC,CAACG;;YACL,IAAIN,QAAQD,kBAAUO,2BAAAA,QAASR,MAAM,mBAAI,EAAE;QAC7C,GACCS,KAAK,CAAC;YACL,IAAIP,QAAQD,UAAU,EAAE;QAC1B;QACF,OAAO;YACLC,SAAS;QACX;IACF,GAAG;QAACJ;QAAQP;KAAK;IAEjB,IAAI,CAACO,QAAQ;QACX,qBACE,KAACzC;YACCiC,KAAKA;WACDI;YACJgB,IAAI;gBACF;oBACEC,GAAG;oBACHC,QAAQ;oBACRC,aAAa;oBACbC,cAAc;oBACdC,OAAO;oBACPC,UAAU;oBACVC,YAAY;gBACd;mBACGtB;aACJ;sBAEA;;IAGP;IACA,IAAIK,WAAW,MAAM;QACnB,qBACE,KAAC3C;YAAIiC,KAAKA;WAASI;YAAMgB,IAAI;gBAAC;oBAAEC,GAAG;gBAAE;mBAAMhB;aAAO;sBAChD,cAAA,KAACrC;gBAAiB4D,MAAM;;;IAG9B;IACA,MAAMC,UAAUnB,OAAOoB,KAAK,CAAC,GAAG3B,YAAYA,WAAW,IAAIA,WAAW;IACtE,IAAI,CAAC0B,QAAQE,MAAM,EAAE,qBAAO,KAAChE;QAAIiC,KAAKA;OAASI;IAE/C,qBACE,MAACnC;QAAM+B,KAAKA;QAAKgC,SAAS;OAAO5B;;YAC9BF,wBAAU,KAAChC;gBAAW+D,SAAQ;0BAAM/B;iBAAwB;YAC5D2B,QAAQK,GAAG,CAAC,CAACxD,sBACZ,MAACT;oBAECkE,WAAU;oBACVH,SAAS;oBACTZ,IAAI;wBAAEgB,YAAY;oBAAa;;wBAO9B1D,MAAMI,UAAU,IACjB,CAACjB,MAAMwE,uBAAuB,CAAC3D,MAAMI,UAAU,kBAC7C,KAACf;4BACCuE,WAAU;4BACVC,KAAK7D,MAAMI,UAAU;4BACrB,+DAA+D;4BAC/D,8DAA8D;4BAC9D,4DAA4D;4BAC5D,oDAAoD;4BACpD0D,KAAK3E,MAAM4E,gBAAgB,CAAC/D,MAAMgE,aAAa;4BAC/CtB,IAAI;gCACFuB,OAAO;gCACPC,QAAQ;gCACRC,WAAW;gCACXrB,cAAc;gCACdsB,YAAY;4BACd;2BAKIjF,MAAMkF,yBAAyB,KAEnC;sCACJ,MAAC9E;4BAAM+D,SAAS;4BAAMZ,IAAI;gCAAE4B,UAAU;4BAAE;;8CACtC,KAAC9E;oCAAW+D,SAAQ;8CAAMvD,MAAMS,KAAK;;8CACrC,MAACjB;oCAAW+D,SAAQ;oCAAQR,OAAM;;wCAC/B,IAAIpC,KAAKX,MAAMY,UAAU,EAAE2D,cAAc,CAAC,EAAE,EAAE;4CAC7CC,WAAW;4CACXC,WAAW;wCACb;wCACCzE,MAAMgB,QAAQ,GAAG,CAAC,GAAG,EAAEhB,MAAMgB,QAAQ,EAAE,GAAG;wCAC1ChB,MAAMiB,SAAS,GAAG,CAAC,GAAG,EAAEjB,MAAMiB,SAAS,EAAE,GAAG;;;gCAE9CjB,MAAMkB,WAAW,iBAChB,KAAC1B;oCAAW+D,SAAQ;8CAASvD,MAAMkB,WAAW;qCAC5C;;;sCAGN,KAACwD;4BACCC,MAAK;4BACLC,yBAAyB;gCAAEC,QAAQ9E,YAAYC;4BAAO;;;mBAnDnDA,MAAM8E,GAAG;;;AAyDxB;AACA1D,UAAU2D,WAAW,GAAG;AAExB,OAAO,MAAMC,SAAgD;IAC3DF,KAAKhF;IACLmF,UAAUrF;IACVmF,aAAa;IACb7D,aACE;IACFgE,UAAU/F,MAAMgG,iBAAiB,CAACC,YAAY;IAC9CC,MAAM;QACJC,MAAMlG,gBAAgBkG,IAAI;QAC1B5C,IAAI;YAAEK,OAAO;QAAU;IACzB;IACAwC,OAAO;QACLC,aAAarG,MAAMsG,YAAY,CAACC,OAAO;IACzC;IACAC,YAAY;QACV;YACEnF,MAAM;YACNU,aAAa;YACb0C,WAAWzE,MAAMyG,kBAAkB,CAACC,UAAU;YAC9CC,OAAO;QACT;QACA;YACEtF,MAAM;YACNU,aAAa;YACb0C,WAAWzE,MAAMyG,kBAAkB,CAACG,MAAM;YAC1CD,OAAO;YACPE,SAAS;gBACP;oBAAEC,OAAO;oBAAIH,OAAO;gBAAqB;gBACzC;oBAAEG,OAAO;oBAAQH,OAAO;gBAAc;aACvC;QACH;QACA;YACEtF,MAAM;YACNU,aAAa;YACb0C,WAAWzE,MAAMyG,kBAAkB,CAACC,UAAU;YAC9CC,OAAO;YACPnB,MAAM;QACR;KACD;AACH,EAAC;AAED,OAAO,MAAMuB,UAAgC;IAC3C;QACEpB,KAAKjF,iBAAiBC;QACtB6E,MAAMxF,MAAMgH,QAAQ,CAACC,MAAM;QAC3BrB,aAAa;QACbE,UAAUrF;QACVsB,aAAa;QACbgE,UAAU/F,MAAMgG,iBAAiB,CAACC,YAAY;QAC9CC,MAAM;YACJC,MAAMlG,gBAAgBkG,IAAI;YAC1B5C,IAAI;gBAAEK,OAAO;YAAU;QACzB;QACAsD,MAAM;YACJvB,KAAK;YACLwB,aAAaxG;YACbmF,UAAUrF;YACVyB,OAAO,CAAC;QACV;IACF;CACD,CAAA;AAED,eAAeD,UAAS"}
@@ -0,0 +1,19 @@
1
+ import { type ConsolePluginPageProps } from '@aglyn/aglyn';
2
+ /**
3
+ * Events manager (AGL-145 → AGL-394): the Event Calendar add-on's console
4
+ * surface, now owned by the plugin so the console shell renders it through
5
+ * the ConsoleExtension registry rather than a hardcoded page. Events carry
6
+ * schedule/location/organizer/cover and a draft/published status; visitors
7
+ * see published events through the Event List canvas element.
8
+ *
9
+ * Reaching this component means the org holds `eventCalendar`. The shell
10
+ * resolves that entitlement and renders its own refusal instead of mounting
11
+ * anything an extension registered (AGL-2484), so there is no unentitled
12
+ * state to render here; the sentence a refused org reads is registered as
13
+ * this plugin's `upgradeNotice` in `plugin.ts`.
14
+ */
15
+ export declare function EventsConsolePage(props: ConsolePluginPageProps): import("react").JSX.Element;
16
+ export declare namespace EventsConsolePage {
17
+ var displayName: string;
18
+ }
19
+ export default EventsConsolePage;
@@ -0,0 +1,530 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
+ import { createResourceUid, MEDIA_ALT_MAX_LENGTH, pluginDocsHelp } from "@aglyn/aglyn";
20
+ import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
21
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
22
+ import { Timestamp } from "@aglyn/shared-util-timestamp";
23
+ import { ceilingedWindow, useFirestore, useFirestoreCollection, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
24
+ import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from "@mui/material";
25
+ import { collection, deleteField, doc, limit, orderBy, query, setDoc, updateDoc } from "firebase/firestore";
26
+ import { useCallback, useMemo, useState } from "react";
27
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
28
+ import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
29
+ /**
30
+ * How many event documents this page reads.
31
+ *
32
+ * A CEILING, not a page size: the rows are filtered for soft deletes and
33
+ * re-sorted after reading, so a server page would arrive holding anywhere
34
+ * from zero to ten events and the count under it would be about candidates
35
+ * rather than about events.
36
+ */ const EVENT_CEILING = 200;
37
+ /**
38
+ * A host's events.
39
+ *
40
+ * This was a hand-rolled `onSnapshot` with its own retry, and it carried a
41
+ * FOURTH copy of the AGL-1066 defect: `attempt = 0` in the success handler.
42
+ * Under `persistentLocalCache` the cached emission that precedes every
43
+ * refusal handed the budget back, so `unreadable` — the whole point of the
44
+ * counter — could not fire in the one state that needed it, a populated
45
+ * editor over a read that had stopped working.
46
+ *
47
+ * Rather than repair a fourth counter, the copy is gone. `useFirestoreCollection`
48
+ * is the same `onSnapshot`-plus-retry that absorbs the AGL-216/217
49
+ * post-sign-in denial, and it carries the signals this page's save needs
50
+ * (`fromCache`, and `serverDenied` for the refusal the budget cannot yet
51
+ * express) plus denial reporting into `session-health` this page never had.
52
+ * There was never a reason it could not: the file has imported from
53
+ * `@aglyn/tenant-feature-instance` all along.
54
+ */ function useHostEvents(hostId) {
55
+ const firestore = useFirestore();
56
+ const { data, status, fromCache } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'events'), /*
57
+ * ORDERED, so the window is the newest 200 rather than a sample.
58
+ *
59
+ * A `limit()` with no `orderBy` returns documents in ID order, so
60
+ * this was reading an arbitrary 200 of the collection and then
61
+ * sorting them by date in the browser — which looks newest-first
62
+ * and is not: a site past 200 events would simply never see the
63
+ * ones whose ids sorted late, including the next one happening.
64
+ *
65
+ * `orderBy` DROPS documents missing the field, so it is only safe
66
+ * once every writer is known to set it. All three checks pass for
67
+ * `startsAtMs`: the only writer refuses a save without it (the
68
+ * guard a few lines below), the server feed already orders by it,
69
+ * and `events` is not in `IMPORTABLE_FIELDS`, so nothing else
70
+ * writes a row here.
71
+ */ orderBy('startsAtMs', 'desc'), /*
72
+ * One document more than the ceiling, so "there is more than
73
+ * this" is a fact rather than a comparison against the cap —
74
+ * which is wrong in exactly the case it matters, a site holding
75
+ * precisely 200 events. The probe row is dropped below and is
76
+ * never rendered or counted.
77
+ */ limit(EVENT_CEILING + 1)) : null, [
78
+ firestore,
79
+ hostId
80
+ ], {
81
+ idField: '$id'
82
+ });
83
+ const { rows, truncated } = ceilingedWindow(data, EVENT_CEILING);
84
+ return {
85
+ events: rows,
86
+ truncated,
87
+ fromCache,
88
+ unreadable: status === 'error'
89
+ };
90
+ }
91
+ /** datetime-local ↔ epoch-ms without timezone surprises. */ function toLocalInput(ms) {
92
+ if (!ms) return '';
93
+ const date = new Date(ms);
94
+ const pad = (value)=>String(value).padStart(2, '0');
95
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` + `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
96
+ }
97
+ /**
98
+ * Events manager (AGL-145 → AGL-394): the Event Calendar add-on's console
99
+ * surface, now owned by the plugin so the console shell renders it through
100
+ * the ConsoleExtension registry rather than a hardcoded page. Events carry
101
+ * schedule/location/organizer/cover and a draft/published status; visitors
102
+ * see published events through the Event List canvas element.
103
+ *
104
+ * Reaching this component means the org holds `eventCalendar`. The shell
105
+ * resolves that entitlement and renders its own refusal instead of mounting
106
+ * anything an extension registered (AGL-2484), so there is no unentitled
107
+ * state to render here; the sentence a refused org reads is registered as
108
+ * this plugin's `upgradeNotice` in `plugin.ts`.
109
+ */ export function EventsConsolePage(props) {
110
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7;
111
+ var _draft_coverImage;
112
+ const { hostId } = props;
113
+ const firestore = useFirestore();
114
+ const { enqueueSnackbar } = useSnackbar();
115
+ const { confirm } = useConfirmationContext();
116
+ const { events: eventDocs, /**
117
+ * The rows this editor is seeded from are unconfirmed by the server
118
+ * (AGL-1358). Editing copies a whole stored event into `draft` and one
119
+ * `setDoc` writes all of it back, so `merge: true` protects nothing.
120
+ * `status` is the field that costs the most: a cached seed can
121
+ * UNPUBLISH a live, indexed event — or republish one that was pulled
122
+ * down — from the Save button of an unrelated edit, and `endsAtMs` is
123
+ * rewritten on every save whether or not anyone touched the schedule.
124
+ */ fromCache: eventsFromCache, truncated: eventsTruncated, unreadable: eventsUnreadable } = useHostEvents(hostId);
125
+ const events = eventDocs.filter((event)=>!event.deletedAt).sort((a, b)=>{
126
+ var _b_startsAtMs, _a_startsAtMs;
127
+ return ((_b_startsAtMs = b.startsAtMs) != null ? _b_startsAtMs : 0) - ((_a_startsAtMs = a.startsAtMs) != null ? _a_startsAtMs : 0);
128
+ });
129
+ /*
130
+ * The page is a SLICE of the ceiling this page already holds. Sorting is
131
+ * allowed here for the reason it is not allowed on a server-paged list: the
132
+ * whole window is in hand, so newest-first is the order of the window and
133
+ * not of one arbitrary page inside it.
134
+ */ const [page, setPage] = useState(0);
135
+ const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
136
+ const visibleEvents = useMemo(()=>events.slice(page * pageSize, page * pageSize + pageSize), [
137
+ events,
138
+ page,
139
+ pageSize
140
+ ]);
141
+ const [draft, setDraft] = useState(null);
142
+ const handleSave = useCallback(async ()=>{
143
+ if (!draft || !draft.title.trim()) return;
144
+ const startsAtMs = draft.startsAt ? new Date(draft.startsAt).getTime() : 0;
145
+ const endsAtMs = draft.endsAt ? new Date(draft.endsAt).getTime() : 0;
146
+ if (!startsAtMs) {
147
+ return void enqueueSnackbar('Set a start time', {
148
+ variant: 'warning',
149
+ persist: false
150
+ });
151
+ }
152
+ try {
153
+ var _draft_id;
154
+ const id = (_draft_id = draft.id) != null ? _draft_id : createResourceUid();
155
+ /**
156
+ * Refuse an EDIT whose seed the server never confirmed (AGL-1358).
157
+ *
158
+ * One `setDoc` serves create and edit here, so the guard has to be
159
+ * conditioned on `draft.id` rather than sitting on a branch of its
160
+ * own. A NEW event is built from blanks at a fresh uid and can
161
+ * overwrite nothing, while the first snapshot of any listener is
162
+ * `fromCache: true` — an unconditional guard would refuse every
163
+ * create, which is the common case, not a corner.
164
+ *
165
+ * The guard WRAPS the write. An early return is a shape you can keep
166
+ * while losing the protection; here the write is only reachable
167
+ * through the verdict.
168
+ */ const verdict = await writeGuardedBySeed({
169
+ subject: 'event',
170
+ unreadable: Boolean(draft.id) && eventsUnreadable,
171
+ fromCache: Boolean(draft.id) && eventsFromCache
172
+ }, async ()=>{
173
+ await setDoc(doc(firestore, 'hosts', hostId, 'events', id), _extends({
174
+ title: draft.title.trim().slice(0, 150),
175
+ startsAtMs
176
+ }, endsAtMs > startsAtMs ? {
177
+ endsAtMs
178
+ } : {
179
+ endsAtMs: startsAtMs + 60 * 60 * 1000
180
+ }, draft.location.trim() && {
181
+ location: draft.location.trim().slice(0, 200)
182
+ }, draft.organizer.trim() && {
183
+ organizer: draft.organizer.trim().slice(0, 100)
184
+ }, draft.description.trim() && {
185
+ description: draft.description.trim().slice(0, 2000)
186
+ }, draft.coverImage.trim() && {
187
+ coverImage: draft.coverImage.trim()
188
+ }, draft.coverImage.trim() && draft.coverImageAlt.trim() ? {
189
+ coverImageAlt: draft.coverImageAlt.trim().slice(0, MEDIA_ALT_MAX_LENGTH)
190
+ } : {
191
+ coverImageAlt: deleteField()
192
+ }, {
193
+ status: draft.status,
194
+ updatedAt: Timestamp.now()
195
+ }, draft.id ? {} : {
196
+ createdAt: Timestamp.now()
197
+ }), {
198
+ merge: true
199
+ });
200
+ });
201
+ // Before `setDraft(null)`, so a refusal keeps the dialog open with
202
+ // what was typed — in the same warning vocabulary as the start-time
203
+ // refusal above it.
204
+ if (!verdict.ok) {
205
+ return void enqueueSnackbar(verdict.message, {
206
+ variant: 'warning',
207
+ persist: false
208
+ });
209
+ }
210
+ setDraft(null);
211
+ enqueueSnackbar('Event saved', {
212
+ variant: 'success',
213
+ persist: false
214
+ });
215
+ } catch (error) {
216
+ console.error(error);
217
+ enqueueSnackbar('An error has occurred', {
218
+ variant: 'error',
219
+ allowDuplicate: true
220
+ });
221
+ }
222
+ }, [
223
+ draft,
224
+ firestore,
225
+ hostId,
226
+ enqueueSnackbar,
227
+ eventsFromCache,
228
+ eventsUnreadable
229
+ ]);
230
+ const handleDelete = useCallback((event)=>async ()=>{
231
+ const confirmed = await confirm({
232
+ title: 'Delete this event?',
233
+ description: `"${event.title}" disappears from your site.`,
234
+ confirmationText: 'Delete',
235
+ confirmationButtonProps: {
236
+ color: 'error'
237
+ }
238
+ }).then(()=>true).catch(()=>false);
239
+ if (!confirmed) return;
240
+ await updateDoc(doc(firestore, 'hosts', hostId, 'events', event.$id), {
241
+ deletedAt: Timestamp.now()
242
+ });
243
+ }, [
244
+ confirm,
245
+ firestore,
246
+ hostId
247
+ ]);
248
+ return /*#__PURE__*/ _jsxs(CardDisplay, {
249
+ header: 'Events',
250
+ help: pluginDocsHelp('events', {
251
+ anchor: '#manage-events'
252
+ }),
253
+ contentGutterX: true,
254
+ contentGutterY: true,
255
+ children: [
256
+ /*#__PURE__*/ _jsxs(Stack, {
257
+ spacing: 1,
258
+ children: [
259
+ events.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
260
+ variant: "body2",
261
+ color: "text.secondary",
262
+ children: 'Create events here, then drop an Event List element on any ' + 'screen — published events render with SEO Event markup.'
263
+ }) : visibleEvents.map((event)=>{
264
+ var _event_status, _event_startsAtMs;
265
+ return /*#__PURE__*/ _jsxs(Stack, {
266
+ direction: "row",
267
+ spacing: 1,
268
+ sx: {
269
+ alignItems: 'center'
270
+ },
271
+ children: [
272
+ /*#__PURE__*/ _jsx(Chip, {
273
+ size: "small",
274
+ label: (_event_status = event.status) != null ? _event_status : 'draft',
275
+ color: event.status === 'published' ? 'success' : 'default'
276
+ }),
277
+ /*#__PURE__*/ _jsxs(Stack, {
278
+ sx: {
279
+ flex: 1,
280
+ minWidth: 0
281
+ },
282
+ children: [
283
+ /*#__PURE__*/ _jsx(Typography, {
284
+ variant: "body2",
285
+ noWrap: true,
286
+ children: event.title
287
+ }),
288
+ /*#__PURE__*/ _jsxs(Typography, {
289
+ variant: "caption",
290
+ color: "text.secondary",
291
+ noWrap: true,
292
+ children: [
293
+ new Date((_event_startsAtMs = event.startsAtMs) != null ? _event_startsAtMs : 0).toLocaleString(),
294
+ event.location ? ` · ${event.location}` : ''
295
+ ]
296
+ })
297
+ ]
298
+ }),
299
+ /*#__PURE__*/ _jsx(Button, {
300
+ size: "small",
301
+ onClick: ()=>{
302
+ var _event_title, _event_location, _event_organizer, _event_description, _event_coverImage, _event_coverImageAlt, _event_status;
303
+ return setDraft({
304
+ id: event.$id,
305
+ title: (_event_title = event.title) != null ? _event_title : '',
306
+ startsAt: toLocalInput(event.startsAtMs),
307
+ endsAt: toLocalInput(event.endsAtMs),
308
+ location: (_event_location = event.location) != null ? _event_location : '',
309
+ organizer: (_event_organizer = event.organizer) != null ? _event_organizer : '',
310
+ description: (_event_description = event.description) != null ? _event_description : '',
311
+ coverImage: (_event_coverImage = event.coverImage) != null ? _event_coverImage : '',
312
+ coverImageAlt: (_event_coverImageAlt = event.coverImageAlt) != null ? _event_coverImageAlt : '',
313
+ status: (_event_status = event.status) != null ? _event_status : 'draft'
314
+ });
315
+ },
316
+ children: 'Edit'
317
+ }),
318
+ /*#__PURE__*/ _jsx(Button, {
319
+ size: "small",
320
+ color: "error",
321
+ onClick: handleDelete(event),
322
+ children: 'Delete'
323
+ })
324
+ ]
325
+ }, event.$id);
326
+ }),
327
+ events.length === 0 ? null : /*#__PURE__*/ _jsx(ListPagination, {
328
+ page: page,
329
+ pageSize: pageSize,
330
+ rowCount: visibleEvents.length,
331
+ // The events this page HOLDS, after the soft-deleted ones are
332
+ // dropped — a slice of rows already read, so the total is exact
333
+ // for the window. The alert below says when the window is short
334
+ // of the site.
335
+ count: events.length,
336
+ onPageChange: setPage,
337
+ onPageSizeChange: setPageSize
338
+ }),
339
+ eventsTruncated ? /*#__PURE__*/ _jsx(Alert, {
340
+ severity: "info",
341
+ children: `Showing the newest ${EVENT_CEILING} events. This site has ` + 'more, and the older ones are not reachable from here.'
342
+ }) : null,
343
+ /*#__PURE__*/ _jsx(Button, {
344
+ size: "small",
345
+ color: "primary",
346
+ sx: {
347
+ alignSelf: 'flex-start'
348
+ },
349
+ onClick: ()=>setDraft({
350
+ id: null,
351
+ title: '',
352
+ startsAt: '',
353
+ endsAt: '',
354
+ location: '',
355
+ organizer: '',
356
+ description: '',
357
+ coverImage: '',
358
+ coverImageAlt: '',
359
+ status: 'draft'
360
+ }),
361
+ children: 'Add event'
362
+ })
363
+ ]
364
+ }),
365
+ /*#__PURE__*/ _jsxs(Dialog, {
366
+ open: Boolean(draft),
367
+ onClose: ()=>setDraft(null),
368
+ maxWidth: "sm",
369
+ fullWidth: true,
370
+ children: [
371
+ /*#__PURE__*/ _jsx(DialogTitle, {
372
+ children: (draft == null ? void 0 : draft.id) ? 'Edit event' : 'Add event'
373
+ }),
374
+ /*#__PURE__*/ _jsxs(DialogContent, {
375
+ sx: {
376
+ display: 'flex',
377
+ flexDirection: 'column',
378
+ gap: 1.5,
379
+ pt: 1
380
+ },
381
+ children: [
382
+ /*#__PURE__*/ _jsx(TextField, {
383
+ label: "Title",
384
+ value: (_ref = draft == null ? void 0 : draft.title) != null ? _ref : '',
385
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
386
+ title: event.target.value
387
+ }) : prev),
388
+ size: "small",
389
+ autoFocus: true,
390
+ sx: {
391
+ mt: 1
392
+ }
393
+ }),
394
+ /*#__PURE__*/ _jsxs(Stack, {
395
+ direction: "row",
396
+ spacing: 1,
397
+ children: [
398
+ /*#__PURE__*/ _jsx(TextField, {
399
+ label: "Starts",
400
+ type: "datetime-local",
401
+ value: (_ref1 = draft == null ? void 0 : draft.startsAt) != null ? _ref1 : '',
402
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
403
+ startsAt: event.target.value
404
+ }) : prev),
405
+ size: "small",
406
+ slotProps: {
407
+ inputLabel: {
408
+ shrink: true
409
+ }
410
+ },
411
+ sx: {
412
+ flex: 1
413
+ }
414
+ }),
415
+ /*#__PURE__*/ _jsx(TextField, {
416
+ label: "Ends",
417
+ type: "datetime-local",
418
+ value: (_ref2 = draft == null ? void 0 : draft.endsAt) != null ? _ref2 : '',
419
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
420
+ endsAt: event.target.value
421
+ }) : prev),
422
+ size: "small",
423
+ slotProps: {
424
+ inputLabel: {
425
+ shrink: true
426
+ }
427
+ },
428
+ sx: {
429
+ flex: 1
430
+ }
431
+ })
432
+ ]
433
+ }),
434
+ /*#__PURE__*/ _jsxs(Stack, {
435
+ direction: "row",
436
+ spacing: 1,
437
+ children: [
438
+ /*#__PURE__*/ _jsx(TextField, {
439
+ label: "Location",
440
+ value: (_ref3 = draft == null ? void 0 : draft.location) != null ? _ref3 : '',
441
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
442
+ location: event.target.value
443
+ }) : prev),
444
+ size: "small",
445
+ sx: {
446
+ flex: 1
447
+ }
448
+ }),
449
+ /*#__PURE__*/ _jsx(TextField, {
450
+ label: "Organizer",
451
+ value: (_ref4 = draft == null ? void 0 : draft.organizer) != null ? _ref4 : '',
452
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
453
+ organizer: event.target.value
454
+ }) : prev),
455
+ size: "small",
456
+ sx: {
457
+ flex: 1
458
+ }
459
+ })
460
+ ]
461
+ }),
462
+ /*#__PURE__*/ _jsx(TextField, {
463
+ label: "Cover image URL",
464
+ value: (_ref5 = draft == null ? void 0 : draft.coverImage) != null ? _ref5 : '',
465
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
466
+ coverImage: event.target.value
467
+ }) : prev),
468
+ size: "small"
469
+ }),
470
+ (draft == null ? void 0 : (_draft_coverImage = draft.coverImage) == null ? void 0 : _draft_coverImage.trim()) ? /*#__PURE__*/ _jsx(TextField, {
471
+ label: "Cover image description",
472
+ placeholder: "What the picture shows",
473
+ value: (_ref6 = draft == null ? void 0 : draft.coverImageAlt) != null ? _ref6 : '',
474
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
475
+ coverImageAlt: event.target.value.slice(0, MEDIA_ALT_MAX_LENGTH)
476
+ }) : prev),
477
+ size: "small",
478
+ helperText: 'Read aloud by screen readers. Leave empty if the picture is decorative.'
479
+ }) : null,
480
+ /*#__PURE__*/ _jsx(TextField, {
481
+ label: "Description",
482
+ value: (_ref7 = draft == null ? void 0 : draft.description) != null ? _ref7 : '',
483
+ onChange: (event)=>setDraft((prev)=>prev ? _extends({}, prev, {
484
+ description: event.target.value
485
+ }) : prev),
486
+ size: "small",
487
+ multiline: true,
488
+ minRows: 3
489
+ })
490
+ ]
491
+ }),
492
+ /*#__PURE__*/ _jsxs(DialogActions, {
493
+ sx: {
494
+ justifyContent: 'space-between'
495
+ },
496
+ children: [
497
+ /*#__PURE__*/ _jsx(Button, {
498
+ onClick: ()=>setDraft((prev)=>prev ? _extends({}, prev, {
499
+ status: prev.status === 'published' ? 'draft' : 'published'
500
+ }) : prev),
501
+ children: (draft == null ? void 0 : draft.status) === 'published' ? 'Set to draft' : 'Set published'
502
+ }),
503
+ /*#__PURE__*/ _jsxs(Stack, {
504
+ direction: "row",
505
+ spacing: 1,
506
+ children: [
507
+ /*#__PURE__*/ _jsx(Button, {
508
+ onClick: ()=>setDraft(null),
509
+ children: 'Cancel'
510
+ }),
511
+ /*#__PURE__*/ _jsx(Button, {
512
+ variant: "contained",
513
+ color: "primary",
514
+ disabled: !(draft == null ? void 0 : draft.title.trim()),
515
+ onClick: handleSave,
516
+ children: 'Save event'
517
+ })
518
+ ]
519
+ })
520
+ ]
521
+ })
522
+ ]
523
+ })
524
+ ]
525
+ });
526
+ }
527
+ EventsConsolePage.displayName = 'EventsConsolePage';
528
+ export default EventsConsolePage;
529
+
530
+ //# sourceMappingURL=events-console-page.js.map