@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
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
2
2
|
import { actions } from 'astro:actions';
|
|
3
3
|
import type { Settings } from '../lib/schema';
|
|
4
4
|
import type { DesignChange } from '../lib/designDiff';
|
|
5
5
|
import { ImagePicker } from './editor/ImagePicker';
|
|
6
|
-
import ThemeSectionsEditor, { SectionsReview, useDesignApi } from './ThemeSectionsEditor';
|
|
6
|
+
import ThemeSectionsEditor, { SectionsReview, useDesignApi, type SectionFocus } from './ThemeSectionsEditor';
|
|
7
|
+
import { checkLayouts, type Check } from './sections/checks';
|
|
8
|
+
import { EMPTY_TARGETS } from './sections/SectionFields';
|
|
7
9
|
import DesignAccess from './DesignAccess';
|
|
8
10
|
import '../styles/theme-editor.css';
|
|
9
11
|
|
|
@@ -145,7 +147,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
145
147
|
const [copied, setCopied] = useState(false);
|
|
146
148
|
const [tick, setTick] = useState(0);
|
|
147
149
|
const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
|
|
148
|
-
|
|
150
|
+
// open on the device you're most likely designing for: desktop on a wide
|
|
151
|
+
// admin window (scaled to fit), phone otherwise; both are one click away
|
|
152
|
+
const [previewDevice, setPreviewDevice] = useState<DeviceKey>(() => (typeof window !== 'undefined' && window.innerWidth >= 1200 ? 'desktop' : 'mobile'));
|
|
149
153
|
const [previewScheme, setPreviewScheme] = useState<'light' | 'dark'>('light');
|
|
150
154
|
// "What changes" + preview compare / share / history
|
|
151
155
|
const [changes, setChanges] = useState<DesignChange[]>(initialChanges);
|
|
@@ -186,16 +190,45 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
186
190
|
const sectionsStagedRef = useRef(false);
|
|
187
191
|
sectionsStagedRef.current = sectionsStaged;
|
|
188
192
|
|
|
193
|
+
// pre-publish checklist (section layouts)
|
|
194
|
+
const [checks, setChecks] = useState<Check[]>([]);
|
|
195
|
+
const checksRef = useRef<HTMLDialogElement>(null);
|
|
196
|
+
const flushSections = useRef<() => Promise<boolean>>(async () => true);
|
|
197
|
+
// click-to-edit: the preview asks for a section's form; the open form
|
|
198
|
+
// highlights its section in the preview
|
|
199
|
+
const [sectionFocus, setSectionFocus] = useState<SectionFocus | null>(null);
|
|
200
|
+
const openSection = useRef<string | null>(null);
|
|
201
|
+
const frameEl = useRef<HTMLIFrameElement>(null);
|
|
202
|
+
// unsaved section edits, rendered by the server: swap them into the preview
|
|
203
|
+
// (only while it shows the draft; the Live compare stays the live site)
|
|
204
|
+
const compareRef = useRef<'draft' | 'live'>('draft');
|
|
205
|
+
compareRef.current = compare;
|
|
206
|
+
const liveHtml = useCallback((html: string) => {
|
|
207
|
+
if (compareRef.current !== 'draft') return;
|
|
208
|
+
try { frameEl.current?.contentWindow?.postMessage({ type: 'acms-section-html', html }, location.origin); } catch { /* frame navigating */ }
|
|
209
|
+
}, []);
|
|
210
|
+
const focusPreview = (sectionId: string | null) => {
|
|
211
|
+
try { frameEl.current?.contentWindow?.postMessage({ type: 'acms-section-focus', sectionId }, location.origin); } catch { /* frame navigating */ }
|
|
212
|
+
};
|
|
213
|
+
|
|
189
214
|
// the preview iframe reports where it navigated (preview-mode links keep the
|
|
190
215
|
// draft), so reloads after a change land on the same page
|
|
191
216
|
useEffect(() => {
|
|
192
217
|
const onMsg = (e: MessageEvent) => {
|
|
193
|
-
if (e.origin !== location.origin
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
if (e.origin !== location.origin) return;
|
|
219
|
+
if (e.data?.type === 'acms-preview-nav') {
|
|
220
|
+
pathRef.current = String(e.data.path || '/');
|
|
221
|
+
setPreviewPath(pathRef.current);
|
|
222
|
+
} else if (e.data?.type === 'acms-section-edit') {
|
|
223
|
+
setTab('pages');
|
|
224
|
+
setSectionFocus((f) => ({ pageId: String(e.data.pageId), sectionId: String(e.data.sectionId), n: (f?.n ?? 0) + 1 }));
|
|
225
|
+
} else if (e.data?.type === 'acms-sections-ready' && openSection.current) {
|
|
226
|
+
focusPreview(openSection.current);
|
|
227
|
+
}
|
|
196
228
|
};
|
|
197
229
|
addEventListener('message', onMsg);
|
|
198
230
|
return () => removeEventListener('message', onMsg);
|
|
231
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
199
232
|
}, []);
|
|
200
233
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
201
234
|
const [frameBox, setFrameBox] = useState({ w: 0, h: 0 });
|
|
@@ -243,13 +276,23 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
243
276
|
};
|
|
244
277
|
|
|
245
278
|
// Publish design: flush any queued save into the staged blob, then apply it
|
|
246
|
-
const publishDesign = async () => {
|
|
279
|
+
const publishDesign = async (checked = false) => {
|
|
247
280
|
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
|
248
281
|
if (!(await doSave())) return;
|
|
282
|
+
if (!(await flushSections.current())) return;
|
|
249
283
|
const n = latestChanges.current.length;
|
|
250
284
|
if (!n && !sectionsStagedRef.current) return;
|
|
285
|
+
// pre-publish checklist over the staged page layouts
|
|
286
|
+
if (!checked) {
|
|
287
|
+
const d = designApi.data;
|
|
288
|
+
const themeDefs = d?.themes.find((t) => t.slug === activeTheme)?.sections ?? {};
|
|
289
|
+
const staged = (d?.draft?.sectionPages ?? d?.live.sectionPages ?? {}) as Parameters<typeof checkLayouts>[0];
|
|
290
|
+
const found = d ? checkLayouts(staged, themeDefs as Parameters<typeof checkLayouts>[1], d.targets ?? EMPTY_TARGETS) : [];
|
|
291
|
+
if (found.length) { setChecks(found); checksRef.current?.showModal(); return; }
|
|
292
|
+
}
|
|
251
293
|
const what = n ? `${n} ${n === 1 ? 'change' : 'changes'}` : 'the staged page layouts';
|
|
252
|
-
if (!window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
|
|
294
|
+
if (!checked && !window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
|
|
295
|
+
checksRef.current?.close();
|
|
253
296
|
const { error } = await actions.settings.applyDesign({});
|
|
254
297
|
if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
|
|
255
298
|
setPending(false);
|
|
@@ -370,6 +413,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
370
413
|
);
|
|
371
414
|
|
|
372
415
|
const hasDraft = (pending && changes.length > 0) || sectionsStaged;
|
|
416
|
+
// editors change section content only: Pages tab, per-section publish
|
|
417
|
+
const editorOnly = (designApi.data as { role?: string } | null)?.role === 'editor';
|
|
418
|
+
useEffect(() => { if (editorOnly && tab !== 'pages') setTab('pages'); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [editorOnly]);
|
|
373
419
|
|
|
374
420
|
return (
|
|
375
421
|
<div className="dm-layout">
|
|
@@ -384,7 +430,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
384
430
|
<span className={`lm-savestate ${saveState}`}>
|
|
385
431
|
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
|
|
386
432
|
</span>
|
|
387
|
-
<button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
|
|
433
|
+
{!editorOnly && <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>}
|
|
388
434
|
</div>
|
|
389
435
|
</div>
|
|
390
436
|
|
|
@@ -396,10 +442,10 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
396
442
|
<><span className="dm-st is-live">Live</span><span className="dm-st-sub">· no unpublished changes</span></>
|
|
397
443
|
)}
|
|
398
444
|
<span className="dm-status-actions">
|
|
399
|
-
{overrideCount > 0 && (
|
|
445
|
+
{overrideCount > 0 && !editorOnly && (
|
|
400
446
|
<button className="btn sm ghost" type="button" onClick={() => { setOverrides({}); scheduleSave(); }}>Reset all</button>
|
|
401
447
|
)}
|
|
402
|
-
{hasDraft && (
|
|
448
|
+
{hasDraft && !editorOnly && (
|
|
403
449
|
<>
|
|
404
450
|
<button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
|
|
405
451
|
<button className="btn sm primary" type="button" onClick={() => void publishDesign()}>Publish…</button>
|
|
@@ -408,22 +454,22 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
408
454
|
</span>
|
|
409
455
|
</div>
|
|
410
456
|
<div className="dm-status-links">
|
|
411
|
-
{hasDraft && tab !== 'review' && (
|
|
457
|
+
{hasDraft && tab !== 'review' && !editorOnly && (
|
|
412
458
|
<button className="btn sm" type="button" onClick={() => setTab('review')}>Review changes</button>
|
|
413
459
|
)}
|
|
414
460
|
<a className="btn sm" href="/?designPreview=1" target="_blank" rel="noopener">
|
|
415
461
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
|
|
416
462
|
Preview site
|
|
417
463
|
</a>
|
|
418
|
-
<button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
|
|
464
|
+
{!editorOnly && <button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
|
|
419
465
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5" /></svg>
|
|
420
466
|
Share preview
|
|
421
|
-
</button>
|
|
467
|
+
</button>}
|
|
422
468
|
</div>
|
|
423
469
|
</div>
|
|
424
470
|
|
|
425
471
|
<div className="seg te-tabs" role="tablist" aria-label="Design settings" onKeyDown={onTabKey}>
|
|
426
|
-
{TABS.map((t) => (
|
|
472
|
+
{TABS.filter((t) => !editorOnly || t.key === 'pages').map((t) => (
|
|
427
473
|
<button
|
|
428
474
|
key={t.key}
|
|
429
475
|
ref={(el) => { tabRefs.current[t.key] = el; }}
|
|
@@ -734,7 +780,24 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
734
780
|
</div>
|
|
735
781
|
|
|
736
782
|
<div className="te-panel" role="tabpanel" id="dm-panel-pages" aria-labelledby="dm-tab-pages" hidden={tab !== 'pages'}>
|
|
737
|
-
<ThemeSectionsEditor
|
|
783
|
+
<ThemeSectionsEditor
|
|
784
|
+
api={designApi}
|
|
785
|
+
activeTheme={activeTheme}
|
|
786
|
+
unsplashEnabled={unsplashEnabled}
|
|
787
|
+
focus={sectionFocus}
|
|
788
|
+
onOpenSection={(_pageId, sectionId, path) => {
|
|
789
|
+
openSection.current = sectionId;
|
|
790
|
+
// show the page being edited: follow it in the preview
|
|
791
|
+
if (sectionId && path && pathRef.current.split('?')[0] !== path) {
|
|
792
|
+
pathRef.current = path;
|
|
793
|
+
setPreviewPath(path);
|
|
794
|
+
setTick((t) => t + 1);
|
|
795
|
+
} else focusPreview(sectionId);
|
|
796
|
+
}}
|
|
797
|
+
onSaved={() => setTick((t) => t + 1)}
|
|
798
|
+
onLiveHtml={liveHtml}
|
|
799
|
+
flushRef={flushSections}
|
|
800
|
+
/>
|
|
738
801
|
</div>
|
|
739
802
|
|
|
740
803
|
<div className="te-panel" role="tabpanel" id="dm-panel-review" aria-labelledby="dm-tab-review" hidden={tab !== 'review'}>
|
|
@@ -840,6 +903,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
840
903
|
return (
|
|
841
904
|
<div className="dm-frame" ref={frameRef}>
|
|
842
905
|
<iframe
|
|
906
|
+
ref={frameEl}
|
|
843
907
|
key={iframeSrc}
|
|
844
908
|
src={iframeSrc}
|
|
845
909
|
title="Site preview"
|
|
@@ -854,6 +918,38 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
854
918
|
})()}
|
|
855
919
|
</div>
|
|
856
920
|
|
|
921
|
+
<dialog className="modal dm-dialog dm-checks" ref={checksRef} aria-labelledby="dm-checks-title" onClick={(e) => { if (e.target === e.currentTarget) checksRef.current?.close(); }}>
|
|
922
|
+
<div className="modal-head">
|
|
923
|
+
<span id="dm-checks-title">Before you publish</span>
|
|
924
|
+
<button className="modal-x" type="button" onClick={() => checksRef.current?.close()} aria-label="Close">✕</button>
|
|
925
|
+
</div>
|
|
926
|
+
<div className="modal-body">
|
|
927
|
+
{checks.some((c) => c.level === 'error')
|
|
928
|
+
? <p className="dm-dialog-sub">Some fields need fixing before this can go live.</p>
|
|
929
|
+
: <p className="dm-dialog-sub">A few things you may want to fix first. You can also publish as is.</p>}
|
|
930
|
+
<ul className="dm-checklist">
|
|
931
|
+
{checks.slice(0, 12).map((c, i) => (
|
|
932
|
+
<li key={i} className={`is-${c.level}`}>
|
|
933
|
+
<span className="dm-check-dot" aria-hidden="true" />
|
|
934
|
+
<span><b>{c.section}</b>: {c.message}</span>
|
|
935
|
+
<button className="btn sm ghost" type="button" onClick={() => {
|
|
936
|
+
checksRef.current?.close();
|
|
937
|
+
setTab('pages');
|
|
938
|
+
setSectionFocus((f) => ({ pageId: c.pageId, sectionId: c.sectionId, n: (f?.n ?? 0) + 1 }));
|
|
939
|
+
}}>Fix</button>
|
|
940
|
+
</li>
|
|
941
|
+
))}
|
|
942
|
+
{checks.length > 12 && <li className="te-muted">…and {checks.length - 12} more</li>}
|
|
943
|
+
</ul>
|
|
944
|
+
<div className="modal-foot">
|
|
945
|
+
<button className="btn ghost" type="button" onClick={() => checksRef.current?.close()}>Keep editing</button>
|
|
946
|
+
{!checks.some((c) => c.level === 'error') && (
|
|
947
|
+
<button className="btn primary" type="button" onClick={() => void publishDesign(true)}>Publish anyway</button>
|
|
948
|
+
)}
|
|
949
|
+
</div>
|
|
950
|
+
</div>
|
|
951
|
+
</dialog>
|
|
952
|
+
|
|
857
953
|
<dialog className="modal dm-dialog" ref={shareRef} onClick={(e) => { if (e.target === e.currentTarget) shareRef.current?.close(); }}>
|
|
858
954
|
<div className="modal-head">
|
|
859
955
|
<span>Share a preview</span>
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { AdminPushState } from '../lib/push/api';
|
|
3
|
+
import type { PushAuto, PushNotification } from '../lib/push/types';
|
|
4
|
+
import '../styles/mobile-app.css';
|
|
5
|
+
|
|
6
|
+
// Admin → Mobile app: send a notification to the site's own app, choose the
|
|
7
|
+
// automatic notices, see who is subscribed and what was delivered. Owner only;
|
|
8
|
+
// the page exists only when acms({ mobile: { push } }) is on.
|
|
9
|
+
|
|
10
|
+
const ENDPOINT = '/api/push/admin';
|
|
11
|
+
const CUSTOM = '__custom';
|
|
12
|
+
const SOON_MINUTES = [10, 15, 30, 60, 120, 1440];
|
|
13
|
+
|
|
14
|
+
async function api<T>(body?: Record<string, unknown>): Promise<T> {
|
|
15
|
+
const res = await fetch(ENDPOINT, {
|
|
16
|
+
method: body ? 'POST' : 'GET', credentials: 'same-origin',
|
|
17
|
+
headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
|
|
18
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
+
});
|
|
20
|
+
const data = await res.json().catch(() => null);
|
|
21
|
+
if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
|
|
22
|
+
return data as T;
|
|
23
|
+
}
|
|
24
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
25
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
|
26
|
+
const minutesLabel = (m: number) => (m >= 1440 ? '1 day' : m >= 60 ? `${m / 60} hour${m === 60 ? '' : 's'}` : `${m} minutes`);
|
|
27
|
+
|
|
28
|
+
function ago(ms: number, now: number) {
|
|
29
|
+
const s = Math.round((now - ms) / 1000);
|
|
30
|
+
if (s < 60) return 'Just now';
|
|
31
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
32
|
+
const d = new Date(ms);
|
|
33
|
+
const sameDay = new Date(now).toDateString() === d.toDateString();
|
|
34
|
+
return sameDay ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
35
|
+
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon' };
|
|
39
|
+
|
|
40
|
+
export default function MobileApp() {
|
|
41
|
+
const [state, setState] = useState<AdminPushState | null>(null);
|
|
42
|
+
const [loadError, setLoadError] = useState('');
|
|
43
|
+
useEffect(() => { api<AdminPushState>().then(setState, (e) => setLoadError(errText(e))); }, []);
|
|
44
|
+
if (loadError) return <p className="flash error">{loadError}</p>;
|
|
45
|
+
if (!state) return <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /><i /></div>;
|
|
46
|
+
return <MobileAppView state={state} setState={setState} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function MobileAppView({ state, setState }: { state: AdminPushState; setState: (s: AdminPushState) => void }) {
|
|
50
|
+
const topicLabel = useMemo(() => {
|
|
51
|
+
const map = new Map(state.topics.map((t) => [t.id, t.label]));
|
|
52
|
+
for (const c of state.collections) if (!map.has(`collection:${c.key}`)) map.set(`collection:${c.key}`, c.label);
|
|
53
|
+
for (const c of state.calendars) if (!map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
|
|
54
|
+
map.set('*', 'Everyone');
|
|
55
|
+
return (id: string) => map.get(id) ?? id;
|
|
56
|
+
}, [state]);
|
|
57
|
+
const { stats } = state;
|
|
58
|
+
return (
|
|
59
|
+
<div className="ma">
|
|
60
|
+
<div className="kpis ma-kpis">
|
|
61
|
+
<div className="kpi"><small>Devices</small><b>{stats.devices.toLocaleString()}</b></div>
|
|
62
|
+
<div className="kpi"><small>iPhone and iPad</small><b>{stats.platforms.ios.toLocaleString()}</b></div>
|
|
63
|
+
<div className="kpi"><small>Android</small><b>{stats.platforms.android.toLocaleString()}</b></div>
|
|
64
|
+
<div className="kpi"><small>Opened in the last 30 days</small><b>{stats.active30.toLocaleString()}</b></div>
|
|
65
|
+
</div>
|
|
66
|
+
{stats.devices === 0 && (
|
|
67
|
+
<div className="notice ma-notice">
|
|
68
|
+
<div>
|
|
69
|
+
<b>No app has signed up for notifications yet.</b>
|
|
70
|
+
<p>Add the push recipe to your Expo app, then open it on a phone and turn notifications on. From your site folder: <code>npx acms mobile recipe add push</code></p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<Composer state={state} setState={setState} topicLabel={topicLabel} />
|
|
75
|
+
<div className="ma-grid ma-grid-2">
|
|
76
|
+
<Automatic state={state} setState={setState} />
|
|
77
|
+
<Audience state={state} topicLabel={topicLabel} />
|
|
78
|
+
</div>
|
|
79
|
+
<DeliveryLog log={state.log} topicLabel={topicLabel} />
|
|
80
|
+
<p className="hint ma-foot">
|
|
81
|
+
Sent through Expo’s push service{state.expo.customEndpoint ? ' (custom endpoint from ACMS_EXPO_PUSH_URL)' : ''}.{' '}
|
|
82
|
+
{state.expo.accessToken ? 'Expo access token is set.' : 'No Expo access token set; only needed when enhanced push security is on for your Expo project (EXPO_ACCESS_TOKEN).'}{' '}
|
|
83
|
+
Devices that don’t open the app for {state.retentionDays} days are removed.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- composer -----------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function Composer({ state, setState, topicLabel }: { state: AdminPushState; setState: (s: AdminPushState) => void; topicLabel: (id: string) => string }) {
|
|
92
|
+
const [title, setTitle] = useState('');
|
|
93
|
+
const [body, setBody] = useState('');
|
|
94
|
+
const [linkRef, setLinkRef] = useState('');
|
|
95
|
+
const [customUrl, setCustomUrl] = useState('');
|
|
96
|
+
const [mode, setMode] = useState<'all' | 'topics'>('all');
|
|
97
|
+
const [topics, setTopics] = useState<string[]>([]);
|
|
98
|
+
const [count, setCount] = useState<number | null>(state.stats.devices);
|
|
99
|
+
const [busy, setBusy] = useState(false);
|
|
100
|
+
const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
|
|
101
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
102
|
+
|
|
103
|
+
const audience = mode === 'all' ? ['*'] : topics;
|
|
104
|
+
const target = state.links.find((l) => l.ref === linkRef);
|
|
105
|
+
const url = linkRef === CUSTOM ? customUrl.trim() : target?.url ?? '';
|
|
106
|
+
const groups = [...new Set(state.links.map((l) => l.group))];
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (mode === 'all') { setCount(state.stats.devices); return; }
|
|
110
|
+
if (!topics.length) { setCount(0); return; }
|
|
111
|
+
let live = true;
|
|
112
|
+
api<{ recipients: number }>({ action: 'count', audience: topics }).then((r) => live && setCount(r.recipients), () => live && setCount(null));
|
|
113
|
+
return () => { live = false; };
|
|
114
|
+
}, [mode, topics.join('|'), state.stats.devices]);
|
|
115
|
+
|
|
116
|
+
const toggle = (id: string) => setTopics((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
|
117
|
+
const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.'
|
|
118
|
+
: linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
|
|
119
|
+
|
|
120
|
+
const send = async () => {
|
|
121
|
+
setBusy(true); setMsg({});
|
|
122
|
+
try {
|
|
123
|
+
const r = await api<{ notification: PushNotification; state: AdminPushState }>({ action: 'send', title: title.trim(), body: body.trim(), url, audience });
|
|
124
|
+
dialog.current?.close();
|
|
125
|
+
const n = r.notification;
|
|
126
|
+
setMsg(n.status === 'failed' ? { err: `Not delivered: ${n.error || 'the push service refused it.'}` }
|
|
127
|
+
: { ok: n.recipients ? `Sent to ${plural(n.ok, 'device')}${n.failed ? ` · ${n.failed} failed` : ''}.` : 'Saved, but no device matched this audience.' });
|
|
128
|
+
if (n.status !== 'failed') { setTitle(''); setBody(''); setLinkRef(''); setCustomUrl(''); }
|
|
129
|
+
setState(r.state);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
dialog.current?.close();
|
|
132
|
+
setMsg({ err: errText(e) });
|
|
133
|
+
} finally { setBusy(false); }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<section className="card card-pad ma-compose" aria-labelledby="ma-compose-h">
|
|
138
|
+
<div className="ma-compose-form">
|
|
139
|
+
<header className="ma-head">
|
|
140
|
+
<h2 id="ma-compose-h">New notification</h2>
|
|
141
|
+
<p>Goes to the lock screen of everyone you choose. Tapping it opens the link in your app.</p>
|
|
142
|
+
</header>
|
|
143
|
+
<label className="field">
|
|
144
|
+
<span>Title <em className="ma-count">{title.length}/80</em></span>
|
|
145
|
+
<input type="text" value={title} maxLength={80} placeholder="Service times changed this Sunday" onChange={(e) => setTitle(e.target.value)} />
|
|
146
|
+
</label>
|
|
147
|
+
<label className="field">
|
|
148
|
+
<span>Message <em className="ma-count">{body.length}/240</em></span>
|
|
149
|
+
<textarea rows={3} value={body} maxLength={240} placeholder="We meet at 10:30 this week only. See you there." onChange={(e) => setBody(e.target.value)} />
|
|
150
|
+
</label>
|
|
151
|
+
<label className="field">
|
|
152
|
+
<span>Opens</span>
|
|
153
|
+
<select value={linkRef} onChange={(e) => setLinkRef(e.target.value)}>
|
|
154
|
+
<option value="">The app’s home screen</option>
|
|
155
|
+
{groups.map((g) => (
|
|
156
|
+
<optgroup key={g} label={g}>
|
|
157
|
+
{state.links.filter((l) => l.group === g).map((l) => <option key={l.ref} value={l.ref}>{l.label}</option>)}
|
|
158
|
+
</optgroup>
|
|
159
|
+
))}
|
|
160
|
+
<option value={CUSTOM}>Custom address…</option>
|
|
161
|
+
</select>
|
|
162
|
+
</label>
|
|
163
|
+
{linkRef === CUSTOM && (
|
|
164
|
+
<label className="field">
|
|
165
|
+
<span>Address</span>
|
|
166
|
+
<input className="mono" type="url" value={customUrl} placeholder="/visit or https://example.com" onChange={(e) => setCustomUrl(e.target.value)} />
|
|
167
|
+
</label>
|
|
168
|
+
)}
|
|
169
|
+
{target && <p className="hint ma-linkhint">Opens {target.url}, in the reader’s language when a translation exists.</p>}
|
|
170
|
+
<fieldset className="ma-audience">
|
|
171
|
+
<legend>Who gets it</legend>
|
|
172
|
+
<div className="seg" role="tablist" aria-label="Audience">
|
|
173
|
+
<button type="button" role="tab" aria-selected={mode === 'all'} onClick={() => setMode('all')}>Everyone</button>
|
|
174
|
+
<button type="button" role="tab" aria-selected={mode === 'topics'} onClick={() => setMode('topics')}>People following…</button>
|
|
175
|
+
</div>
|
|
176
|
+
{mode === 'topics' && (
|
|
177
|
+
<div className="ma-topics">
|
|
178
|
+
{state.topics.map((t) => (
|
|
179
|
+
<label key={t.id} className={`ma-opt${topics.includes(t.id) ? ' on' : ''}`}>
|
|
180
|
+
<input type="checkbox" checked={topics.includes(t.id)} onChange={() => toggle(t.id)} />
|
|
181
|
+
<span>{t.label}</span>
|
|
182
|
+
<em>{(state.stats.topics[t.id] ?? 0).toLocaleString()}</em>
|
|
183
|
+
</label>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</fieldset>
|
|
188
|
+
{msg.ok && <p className="flash" role="status">{msg.ok}</p>}
|
|
189
|
+
{msg.err && <p className="flash error" role="alert">{msg.err}</p>}
|
|
190
|
+
<div className="ma-send">
|
|
191
|
+
<span className="ma-reach">{count === null ? 'Counting…' : count === 0 ? 'No devices match yet' : `Reaches ${plural(count, 'device')}`}</span>
|
|
192
|
+
<button type="button" className="btn primary" disabled={!!problem || busy} title={problem || undefined} onClick={() => dialog.current?.showModal()}>Send…</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<LockScreen state={state} title={title} body={body} />
|
|
196
|
+
<dialog className="modal" ref={dialog} aria-labelledby="ma-confirm-h">
|
|
197
|
+
<div className="modal-head"><span id="ma-confirm-h">Send to {count === null ? 'these devices' : plural(count, 'device')}?</span>
|
|
198
|
+
<button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
|
|
199
|
+
<div className="modal-body">
|
|
200
|
+
<div className="ma-confirm">
|
|
201
|
+
<b>{title || 'Untitled'}</b>
|
|
202
|
+
{body && <p>{body}</p>}
|
|
203
|
+
<small>{mode === 'all' ? 'Everyone with notifications on' : topics.map(topicLabel).join(', ')}{url ? ` · opens ${url}` : ''}</small>
|
|
204
|
+
</div>
|
|
205
|
+
<p className="hint modal-note">It arrives within seconds and can’t be recalled.</p>
|
|
206
|
+
<div className="modal-foot">
|
|
207
|
+
<button type="button" className="btn" onClick={() => dialog.current?.close()}>Cancel</button>
|
|
208
|
+
<button type="button" className="btn primary" disabled={busy} onClick={send}>{busy ? 'Sending…' : 'Send now'}</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</dialog>
|
|
212
|
+
</section>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A phone lock screen with the notification as it will show. Device preview:
|
|
217
|
+
// fixed neutral colors (like the site previews), not admin tokens.
|
|
218
|
+
function LockScreen({ state, title, body }: { state: AdminPushState; title: string; body: string }) {
|
|
219
|
+
const [now, setNow] = useState(() => new Date());
|
|
220
|
+
useEffect(() => { const t = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(t); }, []);
|
|
221
|
+
return (
|
|
222
|
+
<figure className="ma-preview" aria-label="Lock screen preview">
|
|
223
|
+
<div className="ma-phone">
|
|
224
|
+
<div className="ma-phone-screen">
|
|
225
|
+
<div className="ma-clock">
|
|
226
|
+
<small>{now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</small>
|
|
227
|
+
<b>{now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?[AP]M$/i, '')}</b>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="ma-note">
|
|
230
|
+
<div className="ma-note-top">
|
|
231
|
+
<span className="ma-appicon" aria-hidden="true">
|
|
232
|
+
{state.site.icon ? <img src={state.site.icon} alt="" /> : (
|
|
233
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" /></svg>
|
|
234
|
+
)}
|
|
235
|
+
</span>
|
|
236
|
+
<span className="ma-appname">{state.site.name || 'Your app'}</span>
|
|
237
|
+
<span className="ma-now">now</span>
|
|
238
|
+
</div>
|
|
239
|
+
<b className="ma-note-title">{title.trim() || 'Your title'}</b>
|
|
240
|
+
<p className="ma-note-body">{body.trim() || 'Your message appears here.'}</p>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
<figcaption className="hint">Phones shorten long text; keep the key words first.</figcaption>
|
|
245
|
+
</figure>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- automatic ----------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function Automatic({ state, setState }: { state: AdminPushState; setState: (s: AdminPushState) => void }) {
|
|
252
|
+
const [auto, setAuto] = useState<PushAuto>(state.auto);
|
|
253
|
+
const [busy, setBusy] = useState(false);
|
|
254
|
+
const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
|
|
255
|
+
const dirty = JSON.stringify(auto) !== JSON.stringify(state.auto);
|
|
256
|
+
const flip = (list: string[], id: string) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
|
257
|
+
const save = async () => {
|
|
258
|
+
setBusy(true); setMsg({});
|
|
259
|
+
try {
|
|
260
|
+
const r = await api<{ auto: PushAuto; state: AdminPushState }>({ action: 'saveAuto', auto });
|
|
261
|
+
setState(r.state); setAuto(r.auto); setMsg({ ok: 'Saved.' });
|
|
262
|
+
} catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
|
|
263
|
+
};
|
|
264
|
+
const calendarChoices = (key: 'newEvent' | 'startingSoon') => {
|
|
265
|
+
const list = auto[key].calendars;
|
|
266
|
+
const all = list.includes('*');
|
|
267
|
+
const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
|
|
268
|
+
return (
|
|
269
|
+
<div className="ma-choices">
|
|
270
|
+
<label className="ma-check"><input type="checkbox" checked={all} onChange={() => set(all ? [] : ['*'])} /><span>All public calendars</span></label>
|
|
271
|
+
{state.calendars.map((c) => (
|
|
272
|
+
<label key={c.id} className="ma-check"><input type="checkbox" disabled={all} checked={all || list.includes(c.id)} onChange={() => set(flip(list, c.id))} />
|
|
273
|
+
<span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}</span></label>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
return (
|
|
279
|
+
<section className="card card-pad" aria-labelledby="ma-auto-h">
|
|
280
|
+
<header className="ma-head">
|
|
281
|
+
<h2 id="ma-auto-h">Automatic notifications</h2>
|
|
282
|
+
<p>Sent once, when something goes live for the first time. Edits never notify again.</p>
|
|
283
|
+
</header>
|
|
284
|
+
<div className="ma-rules">
|
|
285
|
+
<div className="ma-rule">
|
|
286
|
+
<div className="ma-rule-head"><b>New post published</b><small>Title and summary, opens the post.</small></div>
|
|
287
|
+
<div className="ma-choices">
|
|
288
|
+
{state.collections.map((c) => (
|
|
289
|
+
<label key={c.key} className="ma-check"><input type="checkbox" checked={auto.newPost.collections.includes(c.key)}
|
|
290
|
+
onChange={() => setAuto({ ...auto, newPost: { collections: flip(auto.newPost.collections, c.key) } })} /><span>{c.label}</span></label>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
{state.calendarEnabled && (
|
|
295
|
+
<>
|
|
296
|
+
<div className="ma-rule">
|
|
297
|
+
<div className="ma-rule-head"><b>New event published</b><small>Name, date and place, opens the event.</small></div>
|
|
298
|
+
{calendarChoices('newEvent')}
|
|
299
|
+
</div>
|
|
300
|
+
<div className="ma-rule">
|
|
301
|
+
<div className="ma-rule-head"><b>Starting soon</b><small>A reminder before each event time, once per date.</small></div>
|
|
302
|
+
{calendarChoices('startingSoon')}
|
|
303
|
+
<label className="field ma-lead">
|
|
304
|
+
<span>Send</span>
|
|
305
|
+
<select value={auto.startingSoon.minutes} onChange={(e) => setAuto({ ...auto, startingSoon: { ...auto.startingSoon, minutes: Number(e.target.value) } })}>
|
|
306
|
+
{[...new Set([...SOON_MINUTES, auto.startingSoon.minutes])].sort((a, b) => a - b).map((m) => <option key={m} value={m}>{minutesLabel(m)} before</option>)}
|
|
307
|
+
</select>
|
|
308
|
+
</label>
|
|
309
|
+
</div>
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
{msg.err && <p className="flash error" role="alert">{msg.err}</p>}
|
|
314
|
+
<div className="form-actions">
|
|
315
|
+
<button type="button" className="btn" disabled={!dirty || busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
|
|
316
|
+
{dirty ? <button type="button" className="btn ghost" onClick={() => { setAuto(state.auto); setMsg({}); }}>Discard</button>
|
|
317
|
+
: msg.ok && <span className="ma-saved" role="status">{msg.ok}</span>}
|
|
318
|
+
</div>
|
|
319
|
+
</section>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- audience -----------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function Audience({ state, topicLabel }: { state: AdminPushState; topicLabel: (id: string) => string }) {
|
|
326
|
+
const { stats } = state;
|
|
327
|
+
const rows = [...new Set([...state.topics.map((t) => t.id), ...Object.keys(stats.topics)])]
|
|
328
|
+
.map((id) => ({ id, n: stats.topics[id] ?? 0 }));
|
|
329
|
+
const max = Math.max(1, ...rows.map((r) => r.n));
|
|
330
|
+
return (
|
|
331
|
+
<section className="card card-pad" aria-labelledby="ma-aud-h">
|
|
332
|
+
<header className="ma-head">
|
|
333
|
+
<h2 id="ma-aud-h">Following</h2>
|
|
334
|
+
<p>Topics people turned on in the app. Everyone gets “Everyone” notifications.</p>
|
|
335
|
+
</header>
|
|
336
|
+
<ul className="ma-bars">
|
|
337
|
+
{rows.map((r) => (
|
|
338
|
+
<li key={r.id}>
|
|
339
|
+
<span className="ma-bar-label">{topicLabel(r.id)}<code>{r.id}</code></span>
|
|
340
|
+
<span className="ma-bar" aria-hidden="true"><i style={{ width: `${(r.n / max) * 100}%` }} /></span>
|
|
341
|
+
<b>{r.n.toLocaleString()}</b>
|
|
342
|
+
</li>
|
|
343
|
+
))}
|
|
344
|
+
</ul>
|
|
345
|
+
<p className="hint ma-aud-note">Your app decides which topics to offer; it reads them from <code>/api/v1/push/topics</code>.</p>
|
|
346
|
+
</section>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- delivery log -------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel: (id: string) => string }) {
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
return (
|
|
355
|
+
<section className="card ma-log" aria-labelledby="ma-log-h">
|
|
356
|
+
<header className="ma-head card-pad ma-log-head">
|
|
357
|
+
<h2 id="ma-log-h">Delivery log</h2>
|
|
358
|
+
<p>The last 50 notifications. Failures update after Expo reports back (about 15 minutes).</p>
|
|
359
|
+
</header>
|
|
360
|
+
{!log.length ? <p className="ma-empty">Nothing sent yet.</p> : (
|
|
361
|
+
<div className="ma-scroll">
|
|
362
|
+
<table className="list ma-table">
|
|
363
|
+
<thead><tr><th>Sent</th><th>Notification</th><th>Audience</th><th>From</th><th className="num">Delivered</th></tr></thead>
|
|
364
|
+
<tbody>
|
|
365
|
+
{log.map((n) => (
|
|
366
|
+
<tr key={n.id}>
|
|
367
|
+
<td data-l="Sent" className="ma-when">{ago(n.createdAt, now)}</td>
|
|
368
|
+
<td data-l="Notification">
|
|
369
|
+
<div className="t-title">{n.title}</div>
|
|
370
|
+
{n.body && <div className="ma-body">{n.body}</div>}
|
|
371
|
+
{n.url && <div className="t-sub ma-url">{n.url}</div>}
|
|
372
|
+
</td>
|
|
373
|
+
<td data-l="Audience" className="ma-aud">{n.topics.map(topicLabel).join(', ')}</td>
|
|
374
|
+
<td data-l="From">{SOURCE[n.source]}{n.source === 'manual' && n.author ? ` ${n.author}` : n.source === 'manual' ? ' an owner' : ''}</td>
|
|
375
|
+
<td data-l="Delivered" className="num ma-result">
|
|
376
|
+
{n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
|
|
377
|
+
: !n.recipients ? <span className="ma-st">No devices</span>
|
|
378
|
+
: <>
|
|
379
|
+
<span className="ma-st" data-tone={n.status === 'failed' ? 'bad' : n.failed ? 'warn' : 'ok'}>{n.ok.toLocaleString()} of {n.recipients.toLocaleString()}</span>
|
|
380
|
+
{(n.failed > 0 || n.removed > 0) && <small>{n.failed ? `${n.failed} failed` : ''}{n.failed && n.removed ? ' · ' : ''}{n.removed ? `${n.removed} removed` : ''}</small>}
|
|
381
|
+
{n.error && <small className="ma-err" title={n.error}>{n.error}</small>}
|
|
382
|
+
</>}
|
|
383
|
+
</td>
|
|
384
|
+
</tr>
|
|
385
|
+
))}
|
|
386
|
+
</tbody>
|
|
387
|
+
</table>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</section>
|
|
391
|
+
);
|
|
392
|
+
}
|