@acms-subir/core 0.3.32 → 0.3.33
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 +15 -0
- package/cli/main.mjs +1 -1
- package/cli/migrations.generated.mjs +35 -2
- package/docs/mobile.md +2 -0
- package/integration.mjs +44 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/recipes/announcements/files/app/announcements/[id].tsx +43 -0
- package/mobile-template/recipes/announcements/files/app/announcements/index.tsx +155 -0
- package/mobile-template/recipes/announcements/files/components/AnnouncementBanners.tsx +47 -0
- package/mobile-template/recipes/announcements/files/components/AnnouncementIcon.tsx +21 -0
- package/mobile-template/recipes/announcements/recipe.json +19 -0
- package/package.json +1 -1
- package/src/components/Announcements.tsx +806 -0
- package/src/components/MobileApp.tsx +1 -1
- package/src/layouts/Admin.astro +4 -0
- package/src/layouts/Site.astro +42 -2
- package/src/lib/agent-discovery.ts +6 -2
- package/src/lib/agent.ts +5 -0
- package/src/lib/announcements/agent.ts +49 -0
- package/src/lib/announcements/api.ts +222 -0
- package/src/lib/announcements/config.ts +8 -0
- package/src/lib/announcements/migration.ts +21 -0
- package/src/lib/announcements/pages.ts +54 -0
- package/src/lib/announcements/service.ts +132 -0
- package/src/lib/announcements/store.ts +88 -0
- package/src/lib/announcements/types.ts +78 -0
- package/src/lib/announcements/words.ts +55 -0
- package/src/lib/backup/content.ts +5 -1
- package/src/lib/mobile/openapi.ts +3 -0
- package/src/lib/mobile/schemas.ts +9 -2
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/api.ts +9 -6
- package/src/lib/push/service.ts +8 -1
- package/src/lib/push/store.ts +20 -7
- package/src/lib/push/types.ts +4 -2
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +6 -0
- package/src/lib/repo/types.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +1 -0
- package/src/pages/admin/announcements.astro +18 -0
- package/src/pages/admin/integrations.astro +2 -1
- package/src/pages/admin/settings.astro +2 -0
- package/src/pages/announcements/[id].astro +35 -0
- package/src/pages/announcements/index.astro +61 -0
- package/src/pages/api/announcements.ts +32 -0
- package/src/pages/api/v1/announcements/[id].ts +7 -0
- package/src/pages/api/v1/announcements/index.ts +8 -0
- package/src/pages/api/webhooks/docs.ts +2 -0
- package/src/styles/announcements-site.css +32 -0
- package/src/styles/announcements.css +210 -0
- package/src/styles/site.css +5 -0
- package/src/virtual.d.ts +1 -0
|
@@ -8,6 +8,7 @@ import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/mi
|
|
|
8
8
|
import { PLACES_MIGRATION } from '../maps/migration';
|
|
9
9
|
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
10
10
|
import { SYNC_MIGRATION } from '../sync/migration';
|
|
11
|
+
import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
|
|
11
12
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
12
13
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
13
14
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -676,6 +677,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
676
677
|
`DROP TRIGGER content_version_settings_update`,
|
|
677
678
|
...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal']),
|
|
678
679
|
] },
|
|
680
|
+
// announcements show on public pages, the banner and the app API
|
|
681
|
+
{ version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers('announcements', ANNOUNCEMENT_VISIBLE_COLUMNS)] },
|
|
679
682
|
];
|
|
680
683
|
|
|
681
684
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
@@ -683,6 +686,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
|
683
686
|
...CONTENT_VERSION_COLUMNS,
|
|
684
687
|
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
|
|
685
688
|
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
|
|
689
|
+
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
686
690
|
};
|
|
687
691
|
|
|
688
692
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
|
5
5
|
import { GuestStore } from '../mobile/guest-store';
|
|
6
6
|
import { LiveStore } from '../live/store';
|
|
7
7
|
import { PushStore } from '../push/store';
|
|
8
|
+
import { AnnouncementStore, announcementInsert } from '../announcements/store';
|
|
8
9
|
import { EditorStore } from '../editor/store';
|
|
9
10
|
import { PermissionStore } from '../permissions/store';
|
|
10
11
|
import { BackupStore } from '../backup/store';
|
|
@@ -276,6 +277,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
276
277
|
readonly guests: GuestStore;
|
|
277
278
|
readonly live: LiveStore;
|
|
278
279
|
readonly push: PushStore;
|
|
280
|
+
readonly announcements: AnnouncementStore;
|
|
279
281
|
readonly editor: EditorStore;
|
|
280
282
|
readonly backups: BackupStore;
|
|
281
283
|
readonly permissions: PermissionStore;
|
|
@@ -287,6 +289,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
287
289
|
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
288
290
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
289
291
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
292
|
+
this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
|
|
290
293
|
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
291
294
|
this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
|
|
292
295
|
this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
|
|
@@ -1823,6 +1826,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1823
1826
|
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
|
|
1824
1827
|
],
|
|
1825
1828
|
})),
|
|
1829
|
+
// announcements (a backup made before them leaves the current ones). Restored
|
|
1830
|
+
// rows never push again here: the push ledger keeps announcement:<id>
|
|
1831
|
+
...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
|
|
1826
1832
|
...(content.forms ?? []).map(formInsert),
|
|
1827
1833
|
...(content.formSubmissions ?? []).map(submissionInsert),
|
|
1828
1834
|
...(content.resources ?? []).map((r) => this.resourceInsert(r)),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { LiveStore } from '../live/store';
|
|
|
8
8
|
import type { BookingRequest } from '../backup/content';
|
|
9
9
|
import type { EmailStore } from '../email/store';
|
|
10
10
|
import type { PushStore } from '../push/store';
|
|
11
|
+
import type { AnnouncementStore } from '../announcements/store';
|
|
12
|
+
import type { Announcement } from '../announcements/types';
|
|
11
13
|
import type { EditorStore } from '../editor/store';
|
|
12
14
|
import type { PermissionStore } from '../permissions/store';
|
|
13
15
|
export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
|
|
@@ -62,6 +64,7 @@ export interface ContentRepository {
|
|
|
62
64
|
readonly guests: GuestStore;
|
|
63
65
|
readonly live: LiveStore;
|
|
64
66
|
readonly push: PushStore;
|
|
67
|
+
readonly announcements: AnnouncementStore;
|
|
65
68
|
readonly editor: EditorStore;
|
|
66
69
|
readonly permissions: PermissionStore;
|
|
67
70
|
// Bounded public catalog; fetch limit + 1 IDs without loading every body.
|
|
@@ -319,6 +322,8 @@ export interface ContentRepository {
|
|
|
319
322
|
places?: Place[];
|
|
320
323
|
forms?: SavedForm[];
|
|
321
324
|
formSubmissions?: FormSubmission[];
|
|
325
|
+
// added with announcements; a backup without them leaves the current ones
|
|
326
|
+
announcements?: Announcement[];
|
|
322
327
|
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
323
328
|
|
|
324
329
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { processDueDeliveries } from './webhooks';
|
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
5
|
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
|
+
import { runAnnouncementJobs } from './announcements/service';
|
|
7
8
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
8
9
|
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
9
10
|
import { syncCachePurge } from './cache/purge';
|
|
@@ -22,6 +23,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
22
23
|
approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
23
24
|
email: () => dispatchEmail(repo, locals, 10),
|
|
24
25
|
live: () => syncDueLive(repo, locals),
|
|
26
|
+
// scheduled announcements go up (with their push) and ended ones come down
|
|
27
|
+
announcements: () => runAnnouncementJobs(repo, { locals, now: startedAt }),
|
|
25
28
|
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
26
29
|
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
27
30
|
// expired guest sign-in codes and app sessions unused for 90 days
|
package/src/lib/schema.ts
CHANGED
|
@@ -710,6 +710,7 @@ export const WEBHOOK_EVENTS = [
|
|
|
710
710
|
'design.published', 'media.created', 'media.updated', 'media.deleted', 'site.restored',
|
|
711
711
|
'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
|
|
712
712
|
'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
|
|
713
|
+
'announcement.published',
|
|
713
714
|
] as const;
|
|
714
715
|
export type WebhookEventType = (typeof WEBHOOK_EVENTS)[number];
|
|
715
716
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import Announcements from '../../components/Announcements';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { forbiddenUnless } from '../../lib/permissions/access';
|
|
6
|
+
const repo = await getRepo(Astro.locals);
|
|
7
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'services.manage');
|
|
8
|
+
if (denied) return denied;
|
|
9
|
+
---
|
|
10
|
+
<Admin title="Announcements" section="announcements">
|
|
11
|
+
<div class="page-head">
|
|
12
|
+
<div>
|
|
13
|
+
<h1>Announcements</h1>
|
|
14
|
+
<p class="sub">Write it once: it goes on the announcements list, as a banner and, if you choose, as a push to the app.</p>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<Announcements client:only="react" />
|
|
18
|
+
</Admin>
|
|
@@ -5,6 +5,7 @@ import { getRepo } from '../../lib/repo';
|
|
|
5
5
|
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
6
6
|
import { accessFor } from '../../lib/permissions/access';
|
|
7
7
|
import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
|
|
8
|
+
import { ANNOUNCEMENTS } from '../../lib/announcements/config';
|
|
8
9
|
import { issueApiKey } from '../../lib/api-keys';
|
|
9
10
|
import { sessionRole } from '../../lib/api';
|
|
10
11
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
@@ -243,7 +244,7 @@ MAPS_API_KEY=…`}</pre>
|
|
|
243
244
|
<input type="hidden" name="op" value="hook" />
|
|
244
245
|
<label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
|
|
245
246
|
<div class="field"><span>Send these events</span>
|
|
246
|
-
{WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
|
|
247
|
+
{WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.')) && (ANNOUNCEMENTS.enabled || !e.startsWith('announcement.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
|
|
247
248
|
</div>
|
|
248
249
|
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
|
|
249
250
|
<p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
|
|
@@ -4,6 +4,7 @@ import AiSettings from '../../components/AiSettings';
|
|
|
4
4
|
import MonitoringSettings from '../../components/MonitoringSettings';
|
|
5
5
|
import LegalSettings from '../../components/LegalSettings';
|
|
6
6
|
import { LEGAL } from '../../lib/legal/config';
|
|
7
|
+
import { ANNOUNCEMENTS } from '../../lib/announcements/config';
|
|
7
8
|
import { getRepo } from '../../lib/repo';
|
|
8
9
|
import { accessFor } from '../../lib/permissions/access';
|
|
9
10
|
import type { Permission } from '../../lib/permissions/catalog';
|
|
@@ -381,6 +382,7 @@ const groups = [
|
|
|
381
382
|
<label class="field"><span>Link (optional)</span><input type="text" name="annLink" value={settings.announcement.link} placeholder={`${CONTENT.posts.base}/announcement`} /></label>
|
|
382
383
|
</div>
|
|
383
384
|
<label class="check-row"><input type="checkbox" name="annDismissible" checked={settings.announcement.dismissible} /><span>Visitors can dismiss it <span class="hint">(remembered per browser until the text changes)</span></span></label>
|
|
385
|
+
{ANNOUNCEMENTS.enabled && <p class="hint">A pinned announcement (<a href="/admin/announcements">Announcements</a>) shows here instead while it’s up, in each language and on a schedule.</p>}
|
|
384
386
|
</form>
|
|
385
387
|
</section>
|
|
386
388
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { requestLocale, localePath } from '../../lib/i18n';
|
|
5
|
+
import { siteLang } from '../../lib/dates';
|
|
6
|
+
import { ANNOUNCEMENTS } from '../../lib/announcements/config';
|
|
7
|
+
import { isLive } from '../../lib/announcements/service';
|
|
8
|
+
import { announcementStrings } from '../../lib/announcements/words';
|
|
9
|
+
import { announcementView } from '../../lib/announcements/pages';
|
|
10
|
+
import '../../styles/announcements-site.css';
|
|
11
|
+
|
|
12
|
+
// One announcement while it's up (404 before it goes up and after it comes
|
|
13
|
+
// down). Pushes without a button open this page.
|
|
14
|
+
if (!ANNOUNCEMENTS.enabled || !ANNOUNCEMENTS.pages) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
15
|
+
const repo = await getRepo(Astro.locals);
|
|
16
|
+
const id = Astro.params.id ?? '';
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const a = id.length <= 100 ? await repo.announcements.get(id) : null;
|
|
19
|
+
if (!a || !isLive(a, now) || !(a.listed || a.pinned)) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
20
|
+
const settings = await repo.getSettings();
|
|
21
|
+
const locale = requestLocale(Astro.url, Astro.locals);
|
|
22
|
+
const lang = locale || siteLang(settings);
|
|
23
|
+
const t = announcementStrings(lang);
|
|
24
|
+
const v = announcementView(a, locale, lang, settings.timezone || 'UTC', now);
|
|
25
|
+
---
|
|
26
|
+
<Site title={v.title} description={v.message}>
|
|
27
|
+
<article class="ann-page ann-detail" data-kind={v.kind}>
|
|
28
|
+
<p class="ann-back"><a href={localePath(ANNOUNCEMENTS.base, locale)}>← {t.back}</a></p>
|
|
29
|
+
<p class="ann-meta"><span class="ann-kind">{v.kindLabel}</span> · <time datetime={v.iso}>{v.date}</time>{v.until && <> · {v.until}</>}</p>
|
|
30
|
+
<h1>{v.title}</h1>
|
|
31
|
+
{v.message && <p class="ann-lead">{v.message}</p>}
|
|
32
|
+
{v.details.map((p) => <p>{p}</p>)}
|
|
33
|
+
{v.button && <p><a class="ann-button" href={v.button.href} rel={v.button.external ? 'noopener' : undefined}>{v.button.label}</a></p>}
|
|
34
|
+
</article>
|
|
35
|
+
</Site>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { requestLocale, localePath } from '../../lib/i18n';
|
|
5
|
+
import { siteLang } from '../../lib/dates';
|
|
6
|
+
import { ANNOUNCEMENTS } from '../../lib/announcements/config';
|
|
7
|
+
import { ANNOUNCEMENT_KINDS, type AnnouncementKind } from '../../lib/announcements/types';
|
|
8
|
+
import { announcementStrings } from '../../lib/announcements/words';
|
|
9
|
+
import { announcementView, groupByWhen } from '../../lib/announcements/pages';
|
|
10
|
+
import '../../styles/announcements-site.css';
|
|
11
|
+
|
|
12
|
+
// The website's announcements list (acms({ announcements })): what's up
|
|
13
|
+
// right now, newest first, with a tab per kind. A site file at this path
|
|
14
|
+
// replaces it; GET /api/v1/announcements has the same data.
|
|
15
|
+
if (!ANNOUNCEMENTS.enabled || !ANNOUNCEMENTS.pages) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const settings = await repo.getSettings();
|
|
18
|
+
const locale = requestLocale(Astro.url, Astro.locals);
|
|
19
|
+
const lang = locale || siteLang(settings);
|
|
20
|
+
const t = announcementStrings(lang);
|
|
21
|
+
const tz = settings.timezone || 'UTC';
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const asked = Astro.url.searchParams.get('kind') ?? '';
|
|
24
|
+
const kind = (ANNOUNCEMENT_KINDS as readonly string[]).includes(asked) ? asked as AnnouncementKind : undefined;
|
|
25
|
+
const live = await repo.announcements.live(now, { limit: 200, offset: 0 });
|
|
26
|
+
// tabs only for kinds that have something up (urgent shows under All)
|
|
27
|
+
const kinds = ANNOUNCEMENT_KINDS.filter((k) => k !== 'urgent' && live.some((a) => a.kind === k));
|
|
28
|
+
const shown = live.filter((a) => !kind || a.kind === kind).map((a) => announcementView(a, locale, lang, tz, now));
|
|
29
|
+
const groups = groupByWhen(shown, lang, tz, now);
|
|
30
|
+
const base = localePath(ANNOUNCEMENTS.base, locale);
|
|
31
|
+
const tabs = kinds.length ? [{ href: base, label: t.all, current: !kind }, ...kinds.map((k) => ({ href: base + '?kind=' + k, label: t.kinds[k], current: kind === k }))] : [];
|
|
32
|
+
---
|
|
33
|
+
<Site title={t.title} description={t.intro}>
|
|
34
|
+
<section class="ann-page">
|
|
35
|
+
<header class="ann-head">
|
|
36
|
+
<h1>{t.title}</h1>
|
|
37
|
+
<p>{t.intro}</p>
|
|
38
|
+
</header>
|
|
39
|
+
{tabs.length > 0 && (
|
|
40
|
+
<nav class="ann-tabs" aria-label={t.title}>
|
|
41
|
+
{tabs.map((tab) => <a href={tab.href} aria-current={tab.current ? 'page' : undefined}>{tab.label}</a>)}
|
|
42
|
+
</nav>
|
|
43
|
+
)}
|
|
44
|
+
{groups.length === 0 && <p class="ann-empty">{kind ? t.emptyKind : t.empty}</p>}
|
|
45
|
+
{groups.map((g) => (
|
|
46
|
+
<section class="ann-group" aria-labelledby={'ann-' + g.key}>
|
|
47
|
+
<h2 id={'ann-' + g.key}>{g.label}</h2>
|
|
48
|
+
<ul class="ann-list">
|
|
49
|
+
{g.items.map((a) => (
|
|
50
|
+
<li class="ann-item" data-kind={a.kind}>
|
|
51
|
+
<p class="ann-meta"><span class="ann-kind">{a.kindLabel}</span> · <time datetime={a.iso}>{a.date}</time></p>
|
|
52
|
+
<h3><a href={a.href}>{a.title}</a></h3>
|
|
53
|
+
{a.message && <p class="ann-message">{a.message}</p>}
|
|
54
|
+
{a.button && <a class="ann-action" href={a.button.href} rel={a.button.external ? 'noopener' : undefined}>{a.button.label} →</a>}
|
|
55
|
+
</li>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
</section>
|
|
59
|
+
))}
|
|
60
|
+
</section>
|
|
61
|
+
</Site>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { ApiError, apiError, json, readJson } from '../../lib/api';
|
|
4
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
5
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
6
|
+
import { requirePermission } from '../../lib/permissions/access';
|
|
7
|
+
import { waitUntilFrom } from '../../lib/webhooks';
|
|
8
|
+
import { AdminAnnouncementAction, adminAnnouncementAction, adminAnnouncementsState, requireAnnouncements } from '../../lib/announcements/api';
|
|
9
|
+
|
|
10
|
+
// Admin → Announcements: the people who can send pushes (Connected services).
|
|
11
|
+
export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
12
|
+
try {
|
|
13
|
+
requireAnnouncements();
|
|
14
|
+
const repo = await getRepo(locals);
|
|
15
|
+
await requirePermission(request, cookies, repo, 'services.manage');
|
|
16
|
+
return json(await adminAnnouncementsState(repo, url));
|
|
17
|
+
} catch (e) { return apiError(e); }
|
|
18
|
+
};
|
|
19
|
+
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
20
|
+
try {
|
|
21
|
+
requireAnnouncements();
|
|
22
|
+
const repo = await getRepo(locals);
|
|
23
|
+
await requirePermission(request, cookies, repo, 'services.manage');
|
|
24
|
+
// publishing can reach every installed app: only from this site's admin
|
|
25
|
+
if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
|
|
26
|
+
const input = AdminAnnouncementAction.parse(await readJson(request, 100_000));
|
|
27
|
+
const id = await validateRequestSession(request, cookies, repo);
|
|
28
|
+
const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
|
|
29
|
+
const result = await adminAnnouncementAction(repo, input, { url, author, locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
|
|
30
|
+
return json({ ...result, state: input.action === 'count' ? undefined : await adminAnnouncementsState(repo, url) });
|
|
31
|
+
} catch (e) { return apiError(e); }
|
|
32
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getAnnouncement } from '../../../../lib/announcements/api';
|
|
2
|
+
import { readRoute } from '../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
+
export const GET = readRoute(getAnnouncement);
|
|
6
|
+
export const HEAD = GET;
|
|
7
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { listAnnouncements } from '../../../../lib/announcements/api';
|
|
2
|
+
import { readRoute } from '../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
+
// announcements go up and come down with the clock: not timeless
|
|
6
|
+
export const GET = readRoute(listAnnouncements);
|
|
7
|
+
export const HEAD = GET;
|
|
8
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -23,6 +23,7 @@ design.published: a design draft is published, including its navigation and sect
|
|
|
23
23
|
Restoring design history only stages a draft; publishing it emits this event.
|
|
24
24
|
media.created/updated/deleted: a media record is added, its alt text changes, or it is removed.
|
|
25
25
|
site.restored: a content backup is restored; resync instead of expecting individual record events.
|
|
26
|
+
announcement.published: an announcement goes up (acms({ announcements }); scheduled ones when their time comes).
|
|
26
27
|
Existing event.* and booking.* payloads are described at /api/calendar/docs.
|
|
27
28
|
|
|
28
29
|
## Payloads
|
|
@@ -34,6 +35,7 @@ page.* data: { "page": { "id", "slug", "title", "status", "locale", "isHome" },
|
|
|
34
35
|
design.published data: { "design": { "revision", "activeTheme", "navigationChanged", "sectionPageIds" } }
|
|
35
36
|
media.* data: { "media": { "id", "key", "filename", "mime", "width", "height", "size", "alt" } }
|
|
36
37
|
site.restored data: { "counts": { "posts", "pages", "media" } }
|
|
38
|
+
announcement.published data: { "announcement": { the GET /api/v1/announcements/<id> item in the main language, plus "status" } }
|
|
37
39
|
|
|
38
40
|
Content notifications carry metadata, not document bodies or pending drafts.
|
|
39
41
|
On publication/update, fetch current public content from /api/v1/content/<id>?kind=post (or kind=page).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* Built-in /announcements pages: on the page background (no card), the
|
|
2
|
+
kind in words first, urgent marked by its label rather than colour alone. */
|
|
3
|
+
.ann-page { max-width: 720px; margin: 0 auto; padding: 40px 20px 72px; }
|
|
4
|
+
.ann-head h1, .ann-detail h1 { font-size: clamp(28px, 4vw, 36px); letter-spacing: -0.03em; margin: 0 0 6px; }
|
|
5
|
+
.ann-head p { color: var(--muted); margin: 0 0 24px; }
|
|
6
|
+
.ann-tabs { display: flex; flex-wrap: wrap; gap: 4px 18px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
|
|
7
|
+
.ann-tabs a { color: var(--muted); text-decoration: none; padding: 10px 0; min-height: 44px; display: inline-flex; align-items: center; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
8
|
+
.ann-tabs a:hover { color: var(--ink); }
|
|
9
|
+
.ann-tabs a[aria-current='page'] { color: var(--ink); border-bottom-color: var(--ink); font-weight: 600; }
|
|
10
|
+
.ann-group h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 28px 0 4px; }
|
|
11
|
+
.ann-list { list-style: none; margin: 0; padding: 0; }
|
|
12
|
+
.ann-item { padding: 16px 0; border-bottom: 1px solid var(--line); }
|
|
13
|
+
.ann-item:last-child { border-bottom: 0; }
|
|
14
|
+
.ann-meta { font-size: 13px; color: var(--muted); margin: 0 0 4px; }
|
|
15
|
+
.ann-kind { font-weight: 600; color: var(--ink); }
|
|
16
|
+
[data-kind='urgent'] .ann-kind { text-transform: uppercase; letter-spacing: 0.04em; }
|
|
17
|
+
.ann-item h3 { font-size: 18px; margin: 0 0 4px; letter-spacing: -0.01em; }
|
|
18
|
+
.ann-item h3 a { color: inherit; text-decoration: none; }
|
|
19
|
+
.ann-item h3 a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
20
|
+
.ann-message { margin: 0 0 6px; }
|
|
21
|
+
.ann-action { font-weight: 600; color: var(--accent-text, var(--accent)); text-decoration: none; display: inline-block; padding: 4px 0; }
|
|
22
|
+
.ann-action:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
23
|
+
.ann-empty { color: var(--muted); padding: 32px 0; }
|
|
24
|
+
.ann-back { margin: 0 0 20px; }
|
|
25
|
+
.ann-back a { color: var(--muted); text-decoration: none; }
|
|
26
|
+
.ann-back a:hover { color: var(--ink); }
|
|
27
|
+
.ann-lead { font-size: 18px; }
|
|
28
|
+
.ann-button {
|
|
29
|
+
display: inline-flex; align-items: center; min-height: 44px; padding: 0 20px; border-radius: var(--r-sm);
|
|
30
|
+
background: var(--btn); color: var(--btn-ink); text-decoration: none; font-weight: 600;
|
|
31
|
+
}
|
|
32
|
+
.ann-button:hover { opacity: 0.92; }
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/* Admin → Announcements island. Scoped under .an; admin.css tokens only, except
|
|
2
|
+
the lock-screen and website-banner previews, which show a phone and the
|
|
3
|
+
public site (fixed device/site colours, like mobile-app.css). Kind colours
|
|
4
|
+
reuse the status tokens; red is for urgent only. No pills: statuses are
|
|
5
|
+
coloured text, controls 8–10px rects. */
|
|
6
|
+
.an { display: flex; flex-direction: column; gap: 16px; }
|
|
7
|
+
.an .card + .card { margin-top: 0; }
|
|
8
|
+
.an code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; overflow-wrap: anywhere; }
|
|
9
|
+
.an [data-kind='event'] { --an-c: var(--blue); --an-bg: var(--blue-soft); }
|
|
10
|
+
.an [data-kind='registration'] { --an-c: var(--green); --an-bg: var(--green-soft); }
|
|
11
|
+
.an [data-kind='help'] { --an-c: var(--amber); --an-bg: var(--amber-soft); }
|
|
12
|
+
.an [data-kind='info'] { --an-c: var(--text-2); --an-bg: var(--sel); }
|
|
13
|
+
.an [data-kind='urgent'] { --an-c: var(--red); --an-bg: var(--red-soft); }
|
|
14
|
+
|
|
15
|
+
/* skeleton */
|
|
16
|
+
.an-skel { display: grid; gap: 12px; }
|
|
17
|
+
.an-skel i { display: block; height: 44px; border-radius: var(--r-lg); background: var(--panel-2); animation: an-pulse 1.2s ease-in-out infinite alternate; }
|
|
18
|
+
.an-skel i:nth-child(2) { height: 320px; }
|
|
19
|
+
.an-skel i:nth-child(3) { height: 20px; width: 50%; }
|
|
20
|
+
@keyframes an-pulse { to { opacity: 0.45; } }
|
|
21
|
+
@media (prefers-reduced-motion: reduce) { .an-skel i { animation: none; } }
|
|
22
|
+
|
|
23
|
+
/* flash (an always-present live region; no gap while empty) */
|
|
24
|
+
.an-flash .flash { margin: 0; }
|
|
25
|
+
.an > .an-flash:empty { margin-bottom: -16px; }
|
|
26
|
+
.an .flash.an-warn { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
|
|
27
|
+
.modal-body .an-flash .flash { margin-top: 12px; }
|
|
28
|
+
|
|
29
|
+
/* segmented controls */
|
|
30
|
+
.an-seg { max-width: 100%; }
|
|
31
|
+
.an-seg > button { font-size: 12.5px; padding: 6px 12px; min-height: 32px; }
|
|
32
|
+
.an-seg > button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
33
|
+
.an-seg > button:disabled { cursor: default; }
|
|
34
|
+
.an-seg > button:disabled:not([aria-selected='true']) { opacity: 0.5; }
|
|
35
|
+
.an-n { font-style: normal; font-weight: 500; color: var(--faint); font-variant-numeric: tabular-nums; margin-left: 3px; }
|
|
36
|
+
|
|
37
|
+
/* list */
|
|
38
|
+
.an-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
39
|
+
.an-list { overflow: hidden; }
|
|
40
|
+
.an-rows { list-style: none; margin: 0; padding: 0; }
|
|
41
|
+
.an-row { display: grid; grid-template-columns: 132px minmax(0, 1fr) auto; gap: 6px 16px; align-items: start; padding: 14px 20px; border-bottom: 1px solid var(--line); }
|
|
42
|
+
.an-row:last-child { border-bottom: 0; }
|
|
43
|
+
.an-row:hover { background: var(--hover); }
|
|
44
|
+
.an-kind { display: inline-flex; align-items: center; gap: 8px; min-width: 0; font-size: 12px; font-weight: 600; color: var(--an-c); }
|
|
45
|
+
.an-kind i { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto; border-radius: var(--r-sm); background: var(--an-bg); }
|
|
46
|
+
.an-kind svg { width: 14px; height: 14px; }
|
|
47
|
+
.an-row-main { min-width: 0; }
|
|
48
|
+
.an-row-title { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); overflow-wrap: anywhere; }
|
|
49
|
+
.an-row-title.is-empty { color: var(--muted); font-weight: 500; }
|
|
50
|
+
.an-row-msg { margin: 1px 0 0; color: var(--text-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
51
|
+
.an-row-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin-top: 6px; font-size: 12px; color: var(--muted); }
|
|
52
|
+
.an-where { display: inline-flex; gap: 4px; }
|
|
53
|
+
.an-chip { font-size: 11px; font-weight: 600; color: var(--text-2); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; line-height: 18px; }
|
|
54
|
+
.an-tone { font-weight: 600; }
|
|
55
|
+
.an-tone[data-tone='ok'] { color: var(--green); }
|
|
56
|
+
.an-tone[data-tone='warn'] { color: var(--amber); }
|
|
57
|
+
.an-tone[data-tone='bad'] { color: var(--red); }
|
|
58
|
+
.an-row-actions { display: flex; gap: 4px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
|
|
59
|
+
.an-empty { padding: 40px 20px 44px; }
|
|
60
|
+
.an-empty p { margin: 0; }
|
|
61
|
+
.an-foot { margin: 0 2px; line-height: 1.5; display: flex; flex-wrap: wrap; gap: 4px 16px; }
|
|
62
|
+
.an-del { margin: 0; font-size: 13.5px; color: var(--text-2); overflow-wrap: anywhere; }
|
|
63
|
+
|
|
64
|
+
/* composer: head */
|
|
65
|
+
.an-head { display: flex; flex-direction: column; gap: 4px; }
|
|
66
|
+
.an-back { align-self: flex-start; border: 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: var(--muted); padding: 6px 8px; margin-left: -8px; min-height: 32px; border-radius: var(--r-sm); cursor: pointer; }
|
|
67
|
+
.an-back:hover { color: var(--text); background: var(--hover); }
|
|
68
|
+
.an-back:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
69
|
+
.an-head-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
70
|
+
.an-head h2 { font-size: 17px; margin: 0; letter-spacing: -0.01em; }
|
|
71
|
+
.an-head h2:focus { outline: none; }
|
|
72
|
+
.an-status { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
|
|
73
|
+
|
|
74
|
+
/* composer: form */
|
|
75
|
+
.an-compose { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 32px; align-items: start; }
|
|
76
|
+
.an-form { min-width: 0; }
|
|
77
|
+
.an-sec { padding: 18px 0; border-top: 1px solid var(--line); min-width: 0; }
|
|
78
|
+
.an-sec:first-child { border-top: 0; padding-top: 0; }
|
|
79
|
+
.an-sec:last-child { padding-bottom: 0; }
|
|
80
|
+
.an-fs { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
81
|
+
.an-legend { padding: 0; margin: 0 0 8px; font-size: 13.5px; font-weight: 600; color: var(--text); }
|
|
82
|
+
.an-opt-l { font-weight: 400; color: var(--muted); }
|
|
83
|
+
.an-sec-hint { margin: -6px 0 10px; }
|
|
84
|
+
.an-after { margin: 8px 0 0; line-height: 1.45; }
|
|
85
|
+
.an-ro { margin: 0; font-size: 13.5px; color: var(--text-2); }
|
|
86
|
+
.an-bad { color: var(--red); margin: -6px 0 12px; }
|
|
87
|
+
.an-linkhint { margin: -6px 0 0; }
|
|
88
|
+
.an-sec .field:last-child { margin-bottom: 0; }
|
|
89
|
+
|
|
90
|
+
/* kind: radio cards (selected = --sel fill, like the event editor's mode cards) */
|
|
91
|
+
.an-kinds { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
92
|
+
.an-kind-opt { position: relative; flex: 1 1 104px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--r-md); background: var(--panel); font-size: 12.5px; font-weight: 600; color: var(--text-2); cursor: pointer; }
|
|
93
|
+
.an-kind-opt:hover { border-color: var(--faint); }
|
|
94
|
+
.an-kind-opt.on { background: var(--sel); border-color: var(--muted); color: var(--text); }
|
|
95
|
+
.an-kind-opt:has(input:focus-visible) { box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
96
|
+
.an-kind-opt:has(input:disabled) { opacity: 0.5; cursor: not-allowed; }
|
|
97
|
+
.an-kind-opt input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
|
|
98
|
+
.an-kind-opt i { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--r-sm); background: var(--an-bg); color: var(--an-c); }
|
|
99
|
+
.an-kind-opt svg { width: 15px; height: 15px; }
|
|
100
|
+
.an-kind-opt span { overflow-wrap: anywhere; }
|
|
101
|
+
|
|
102
|
+
/* words */
|
|
103
|
+
.an-langs { margin-bottom: 14px; }
|
|
104
|
+
.an-field { margin-bottom: 14px; }
|
|
105
|
+
.an-field-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 5px; }
|
|
106
|
+
.an-field-top label { font-size: 12px; color: var(--muted); font-weight: 500; }
|
|
107
|
+
.an-count { font-style: normal; color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; flex: 0 0 auto; }
|
|
108
|
+
.an-count[data-near] { color: var(--amber); }
|
|
109
|
+
.an-field > small.hint { display: block; margin-top: 5px; }
|
|
110
|
+
.an-src { margin: 0 0 6px; padding: 7px 10px; border-radius: var(--r-sm); background: var(--panel-2); color: var(--text-2); font-size: 12.5px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; max-height: 7.5em; overflow: auto; }
|
|
111
|
+
.an-src span { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 1px; }
|
|
112
|
+
|
|
113
|
+
/* where */
|
|
114
|
+
.an-toggle { display: grid; grid-template-columns: 18px minmax(0, 1fr); column-gap: 10px; align-items: center; }
|
|
115
|
+
.an-toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; cursor: pointer; }
|
|
116
|
+
.an-toggle label { display: flex; align-items: center; min-height: 36px; font-size: 13.5px; color: var(--text); cursor: pointer; }
|
|
117
|
+
.an-toggle small { grid-column: 2; margin: -6px 0 6px; line-height: 1.45; }
|
|
118
|
+
.an-toggle:has(input:disabled) input, .an-toggle:has(input:disabled) label { cursor: default; }
|
|
119
|
+
.an-toggle:has(input:disabled) label { color: var(--muted); }
|
|
120
|
+
|
|
121
|
+
/* who gets the push (topic choices as in Mobile app) */
|
|
122
|
+
.an-topics { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; margin-top: 10px; }
|
|
123
|
+
.an-opt { display: flex; align-items: center; gap: 9px; padding: 9px 11px; min-height: 40px; border: 1px solid var(--line-strong); border-radius: var(--r-md); background: var(--panel); cursor: pointer; font-size: 13px; }
|
|
124
|
+
.an-opt:hover { background: var(--hover); }
|
|
125
|
+
.an-opt.on { background: var(--sel); color: var(--text); font-weight: 600; }
|
|
126
|
+
.an-opt:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
127
|
+
.an-opt input { accent-color: var(--accent); margin: 0; flex: 0 0 auto; }
|
|
128
|
+
.an-opt span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
129
|
+
.an-opt em { font-style: normal; font-weight: 500; color: var(--faint); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
130
|
+
.an-pace { margin: 12px 0 0; line-height: 1.45; }
|
|
131
|
+
.an-pace[data-tone='warn'] { color: var(--amber); }
|
|
132
|
+
|
|
133
|
+
/* when */
|
|
134
|
+
.an-when { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; }
|
|
135
|
+
.an-when input[type='datetime-local'] { display: block; margin-top: 8px; min-width: 0; max-width: 260px; }
|
|
136
|
+
:root[data-theme='dark'] .an input[type='datetime-local'] { color-scheme: dark; }
|
|
137
|
+
|
|
138
|
+
/* preview: lock screen (device colours) */
|
|
139
|
+
.an-preview { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 10px; position: sticky; top: 72px; min-width: 0; }
|
|
140
|
+
.an-preview-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; align-self: stretch; flex-wrap: wrap; }
|
|
141
|
+
.an-preview-lang { width: auto; max-width: 130px; padding: 4px 8px; font-size: 12.5px; }
|
|
142
|
+
.an-phone { width: 100%; max-width: 280px; min-height: 350px; border-radius: 42px 42px 0 0; padding: 9px 9px 0; background: #1d1c1a; box-shadow: inset 0 0 0 1px #34322e; display: flex; }
|
|
143
|
+
.an-phone.is-off { opacity: 0.6; }
|
|
144
|
+
.an-phone-screen { flex: 1; border-radius: 34px 34px 0 0; background: #33424b; color: #f7f5f2; display: flex; flex-direction: column; align-items: center; padding: 46px 11px 16px; overflow: hidden; min-width: 0; }
|
|
145
|
+
.an-clock { text-align: center; }
|
|
146
|
+
.an-clock small { display: block; font-size: 13px; font-weight: 500; opacity: 0.88; }
|
|
147
|
+
.an-clock b { display: block; font-size: 62px; line-height: 1.05; font-weight: 600; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
|
|
148
|
+
.an-note { margin-top: 30px; width: 100%; background: rgb(246 245 243 / 86%); color: #1c1b19; border-radius: 18px; padding: 10px 12px 11px; box-shadow: 0 6px 18px rgb(0 0 0 / 18%); }
|
|
149
|
+
.an-note-top { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: #5d5a55; margin-bottom: 5px; }
|
|
150
|
+
.an-appicon { width: 20px; height: 20px; border-radius: 5px; background: #fdfcfb; color: #3b3935; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 8%); }
|
|
151
|
+
.an-appicon svg { width: 13px; height: 13px; }
|
|
152
|
+
.an-appicon img { width: 100%; height: 100%; object-fit: cover; }
|
|
153
|
+
.an-appname { font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
154
|
+
.an-now { margin-left: auto; flex: 0 0 auto; }
|
|
155
|
+
/* collapsed: one line of title, two of message; expanded: everything */
|
|
156
|
+
.an-note-title { font-size: 13.5px; line-height: 1.3; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
|
157
|
+
.an-note-body { margin: 1px 0 0; font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
158
|
+
.an-note.is-open .an-note-title, .an-note.is-open .an-note-body { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
|
159
|
+
.an-cap { margin: 0; align-self: stretch; text-align: center; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
160
|
+
|
|
161
|
+
/* preview: website banner (site colours) */
|
|
162
|
+
.an-site { align-self: stretch; }
|
|
163
|
+
.an-site-label { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-bottom: 6px; }
|
|
164
|
+
.an-site-bar { display: flex; align-items: center; gap: 8px; padding: 9px 10px 9px 11px; border-radius: 10px; background: #f3f1ed; color: #1c1b19; font-size: 12.5px; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 7%); }
|
|
165
|
+
.an-site-bar i { display: grid; place-items: center; width: 18px; height: 18px; flex: 0 0 auto; color: #4c4841; }
|
|
166
|
+
.an-site-bar svg { width: 16px; height: 16px; }
|
|
167
|
+
.an-site-bar b { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
168
|
+
.an-site-btn { flex: 0 0 auto; max-width: 45%; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
169
|
+
.an-site-x { flex: 0 0 auto; opacity: 0.55; font-size: 11px; }
|
|
170
|
+
.an-site-bar[data-kind='event'] i { color: #2f5fb3; }
|
|
171
|
+
.an-site-bar[data-kind='registration'] i { color: #127442; }
|
|
172
|
+
.an-site-bar[data-kind='help'] i { color: #93660c; }
|
|
173
|
+
.an-site-bar[data-kind='urgent'] { background: #b42318; color: #fff; box-shadow: none; }
|
|
174
|
+
.an-site-bar[data-kind='urgent'] i { color: #fff; }
|
|
175
|
+
|
|
176
|
+
/* review dialog */
|
|
177
|
+
dialog.modal.an-review { width: min(540px, calc(100vw - 32px)); }
|
|
178
|
+
.an-checks { list-style: none; margin: 0; padding: 0; }
|
|
179
|
+
.an-checks li { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 13px; line-height: 1.45; color: var(--text-2); overflow-wrap: anywhere; }
|
|
180
|
+
.an-checks li:last-child { border-bottom: 0; }
|
|
181
|
+
.an-checks li[data-tone='bad'] { color: var(--red); }
|
|
182
|
+
.an-mark { font-weight: 700; text-align: center; }
|
|
183
|
+
.an-checks li[data-tone='ok'] .an-mark { color: var(--green); }
|
|
184
|
+
.an-checks li[data-tone='warn'] .an-mark { color: var(--amber); }
|
|
185
|
+
.an-checks li[data-tone='bad'] .an-mark { color: var(--red); }
|
|
186
|
+
.an-checks .btn { white-space: nowrap; }
|
|
187
|
+
|
|
188
|
+
@media (max-width: 1000px) {
|
|
189
|
+
.an-compose { grid-template-columns: 1fr; gap: 24px; }
|
|
190
|
+
.an-preview { position: static; border-top: 1px solid var(--line); padding-top: 18px; }
|
|
191
|
+
.an-phone { max-width: 260px; min-height: 300px; }
|
|
192
|
+
.an-clock b { font-size: 52px; }
|
|
193
|
+
}
|
|
194
|
+
@media (max-width: 767px) {
|
|
195
|
+
.an-bar .seg { display: flex; width: 100%; }
|
|
196
|
+
.an-bar .seg > button { flex: 1; padding: 6px 4px; min-height: 36px; }
|
|
197
|
+
.an-bar > .btn.primary { width: 100%; justify-content: center; min-height: 44px; }
|
|
198
|
+
.an-row { grid-template-columns: minmax(0, 1fr); padding: 14px 16px; }
|
|
199
|
+
.an-row-actions { justify-content: flex-start; }
|
|
200
|
+
.an-head-row .page-actions { width: 100%; }
|
|
201
|
+
.an-head-row .page-actions .btn { flex: 1 1 auto; justify-content: center; min-height: 44px; white-space: nowrap; }
|
|
202
|
+
.an-sec .seg { display: flex; }
|
|
203
|
+
.an-sec .seg > button { flex: 1; min-height: 36px; white-space: normal; }
|
|
204
|
+
.an-when { grid-template-columns: 1fr; }
|
|
205
|
+
.an-when input[type='datetime-local'] { max-width: none; }
|
|
206
|
+
.an-topics { grid-template-columns: 1fr; }
|
|
207
|
+
.an-toggle label { min-height: 44px; }
|
|
208
|
+
.an-checks li { grid-template-columns: 18px minmax(0, 1fr); }
|
|
209
|
+
.an-checks .btn { grid-column: 2; justify-self: start; }
|
|
210
|
+
}
|
package/src/styles/site.css
CHANGED
|
@@ -535,12 +535,17 @@ li::marker { color: var(--muted); }
|
|
|
535
535
|
background: var(--btn); color: var(--btn-ink);
|
|
536
536
|
font-size: 14px; padding: 9px 44px; text-align: center; position: relative;
|
|
537
537
|
}
|
|
538
|
+
/* hidden until its script checks it wasn't closed (display: flex would beat the attribute) */
|
|
539
|
+
.announcement-bar[hidden] { display: none; }
|
|
538
540
|
.announcement-bar a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
|
|
539
541
|
.announcement-x {
|
|
540
542
|
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
|
541
543
|
background: none; border: 0; color: inherit; opacity: 0.7; cursor: pointer; font-size: 12px; padding: 6px; border-radius: 6px;
|
|
542
544
|
}
|
|
543
545
|
.announcement-x:hover { opacity: 1; background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
546
|
+
/* pinned announcements: urgent says so in words, never by colour alone */
|
|
547
|
+
.announcement-bar .ann-urgent { font-weight: 700; letter-spacing: 0.01em; }
|
|
548
|
+
.announcement-bar .ann-cta { white-space: nowrap; }
|
|
544
549
|
|
|
545
550
|
/* dynamic post-list blocks (expanded at request time) */
|
|
546
551
|
.dyn-postlist { display: grid; gap: 24px; margin: 32px 0; }
|
package/src/virtual.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ declare module 'virtual:acms-config' {
|
|
|
23
23
|
roles?: { key: string; label: string; summary: string; admin: boolean; home: string }[];
|
|
24
24
|
permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
|
|
25
25
|
members?: { enabled: boolean; base: string; notify: string[] };
|
|
26
|
+
announcements?: { enabled: boolean; base: string; pages: boolean; topics: Partial<Record<'event' | 'registration' | 'help' | 'info', string>> };
|
|
26
27
|
// resolved by integration.mjs (lib/calendar/config.ts documents the shape)
|
|
27
28
|
calendar?: {
|
|
28
29
|
enabled: boolean;
|