@acms-subir/core 0.3.34 → 0.3.35
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 +14 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +2 -2
- package/package.json +1 -1
- package/src/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +44 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -0
- package/src/lib/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-words.ts +62 -0
- package/src/styles/mobile-app.css +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.35
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 85f91a1: A new automatic notification in **Admin → Mobile app**: **When an event changes**, off unless you choose calendars (all public calendars or some, like "Starting soon"). People hear when a published event's next date or time moves ("Now Sun, Oct 26, 10:30 AM (was 9:00 AM)"), its place changes ("New place: Fellowship Hall"), the whole event is cancelled, or a date of a repeating event is cancelled ("Cancelled on Sun, Oct 26", which opens that date). It goes out in each phone's language (English, Spanish, French, German or Portuguese, with the event's translated title) 10 minutes after the last edit, compared with what people last knew, so several quick edits send one notice and an edit undone in time sends none. When the date it touches starts within 3 hours, it waits only a minute, and it isn't sent once that date has started (for a move, once the new time has started).
|
|
8
|
+
|
|
9
|
+
- **Only events tagged** narrows the rule to events with one of a few tags (any case), and **Send to** picks topics instead of the event's own (Events and its calendar), for example classes tagged "Class" in a shared rooms calendar, sent to a site topic `classes`.
|
|
10
|
+
- Every way of changing an event counts: the editor's Update, dragging on the schedule, cancelling a date, the API and agents. Other edits, restoring a date, un-cancelling, past events and events in private or unlisted calendars never notify.
|
|
11
|
+
- No double push: the change notice is skipped when an announcement whose button opens the same event went up with a push in the last hour, and the announcement composer says when an automatic change notice for the event it links to went out in the last 2 hours.
|
|
12
|
+
- While the rule sends to the event's own topics, the Events topic is called "Events and changes" in apps (also in Spanish, French, German and Portuguese).
|
|
13
|
+
- Taps open the event as before; `data.change` says what changed and `source` is `change`. Migration 59.
|
|
14
|
+
|
|
15
|
+
- 6b83f1e: The website's built-in words now follow the page's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` pages, and on a site whose main language is Spanish, these were still in English: the live bar, header marker and floating player; the live video card, including its times ("empezó hace 5 min", "en 3 h") and the updates it shows while the page is open; the skip link; the menu and dark-mode buttons; the footer's Read and Site columns; the automatic Privacy, Terms, Search, Home and Links labels; the code copy button; and the gallery's close button. Screen-reader labels are translated too. Live times use the page's language. Search also comes in French, German and Portuguese now. Menu items you created keep their own labels.
|
|
16
|
+
|
|
3
17
|
## 0.3.34
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
|
@@ -1105,7 +1105,13 @@ var MIGRATIONS = [
|
|
|
1105
1105
|
...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"])
|
|
1106
1106
|
] },
|
|
1107
1107
|
// announcements show on public pages, the banner and the app API
|
|
1108
|
-
{ version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers("announcements", ANNOUNCEMENT_VISIBLE_COLUMNS)] }
|
|
1108
|
+
{ version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers("announcements", ANNOUNCEMENT_VISIBLE_COLUMNS)] },
|
|
1109
|
+
// event-change notices (PushAuto.eventChanges) wait in push_queue until the
|
|
1110
|
+
// edits settle: payload = the schedule people last knew, due_at = send time
|
|
1111
|
+
{ version: 59, statements: [
|
|
1112
|
+
`ALTER TABLE push_queue ADD COLUMN payload TEXT`,
|
|
1113
|
+
`ALTER TABLE push_queue ADD COLUMN due_at INTEGER`
|
|
1114
|
+
] }
|
|
1109
1115
|
];
|
|
1110
1116
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1111
1117
|
...CONTENT_VERSION_COLUMNS,
|
package/docs/mobile.md
CHANGED
|
@@ -272,8 +272,8 @@ keyed by `locale`.
|
|
|
272
272
|
## Push notifications
|
|
273
273
|
|
|
274
274
|
Optional; the owner sends them from **Admin → Mobile app** (manual messages
|
|
275
|
-
plus automatic ones: new post, new event, "starting soon",
|
|
276
|
-
app only registers and chooses topics; it never sends.
|
|
275
|
+
plus automatic ones: new post, new event, "starting soon", event changes,
|
|
276
|
+
"we're live"). The app only registers and chooses topics; it never sends.
|
|
277
277
|
|
|
278
278
|
1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
|
|
279
279
|
default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
|
package/package.json
CHANGED
|
@@ -359,6 +359,16 @@ function toForm(a: Announcement | null, state: State): Form {
|
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
const linkOf = (f: Form, state: State) => (f.linkRef === CUSTOM ? f.customUrl.trim() : state.links.find((l) => l.ref === f.linkRef)?.url ?? '');
|
|
362
|
+
// a link's site path as the server compares it: no origin, language prefix, query or hash ('' = another site)
|
|
363
|
+
function sitePath(link: string, state: State) {
|
|
364
|
+
try {
|
|
365
|
+
const u = new URL(link, state.site.origin || 'https://site.invalid');
|
|
366
|
+
if (state.site.origin && u.origin !== new URL(state.site.origin).origin) return '';
|
|
367
|
+
const path = u.pathname.replace(/(.)\/+$/, '$1');
|
|
368
|
+
const lang = state.languages.extra.find((l) => path === `/${l.code}` || path.startsWith(`/${l.code}/`));
|
|
369
|
+
return lang ? path.slice(lang.code.length + 1) || '/' : path;
|
|
370
|
+
} catch { return ''; }
|
|
371
|
+
}
|
|
362
372
|
|
|
363
373
|
function toInput(f: Form, state: State, original: Announcement | null): AnnouncementInput {
|
|
364
374
|
// once it went up, the server keeps its push, audience and start as they were
|
|
@@ -519,6 +529,9 @@ function Composer({ state, setState, startId, onClose }: {
|
|
|
519
529
|
? (original.push ? (original.notificationId ? 'Pushed when it went up. A push only goes out once.' : 'No push went out when it went up.') : 'A push only goes out when it goes up.')
|
|
520
530
|
: !state.push.enabled ? 'Push notifications are off on this site.'
|
|
521
531
|
: state.push.devices === 0 ? 'No phone has turned notifications on yet.' : undefined;
|
|
532
|
+
// the button opens an event whose automatic change notice just went out: a push would repeat it
|
|
533
|
+
const changed = link ? state.recentChanges.find((c) => c.path === sitePath(link, state)) : undefined;
|
|
534
|
+
const changeNote = changed ? `An automatic change notice for this event went out at ${clock(changed.at)}.` : undefined;
|
|
522
535
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
523
536
|
const showPush = wentUp ? original.push : state.push.enabled && form.push;
|
|
524
537
|
const words = lang === main.code ? form.words : form.translations[lang] ?? BLANK;
|
|
@@ -605,7 +618,7 @@ function Composer({ state, setState, startId, onClose }: {
|
|
|
605
618
|
<legend className="an-legend">Where it shows</legend>
|
|
606
619
|
<Toggle label="Announcements list (app and website)" checked={form.listed} onChange={(v) => set({ listed: v })} />
|
|
607
620
|
<Toggle label="Push to phones" checked={wentUp ? original.push : form.push && state.push.enabled} disabled={wentUp || !state.push.enabled}
|
|
608
|
-
note={pushNote} onChange={(v) => set({ push: v })} />
|
|
621
|
+
note={pushNote ?? changeNote} onChange={(v) => set({ push: v })} />
|
|
609
622
|
<Toggle label="Pin as a banner until it ends" checked={form.pinned} note="Closing it hides this announcement only; the next one still shows." onChange={(v) => set({ pinned: v })} />
|
|
610
623
|
</fieldset>
|
|
611
624
|
</div>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { getRepo } from '../lib/repo';
|
|
3
3
|
import { liveState, publicSnapshot } from '../lib/live/service';
|
|
4
4
|
import { liveView } from '../lib/live/view';
|
|
5
|
+
import { liveStrings } from '../lib/live/words';
|
|
6
|
+
import { I18N, requestLocale } from '../lib/i18n';
|
|
7
|
+
import { siteLang, siteLocale } from '../lib/dates';
|
|
5
8
|
import '../styles/live.css';
|
|
6
9
|
|
|
7
10
|
// One component for every live placement. Layout:
|
|
@@ -10,10 +13,16 @@ import '../styles/live.css';
|
|
|
10
13
|
// Theme hooks: .pc-live-* classes, --live-* tokens, [data-layout], [data-status].
|
|
11
14
|
interface Props { compact?: boolean; display?: string }
|
|
12
15
|
const { compact = false, display = 'card' } = Astro.props;
|
|
13
|
-
const
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const [{ config, snapshot: raw }, settings] = await Promise.all([liveState(repo), repo.getSettings()]);
|
|
14
18
|
const snapshot = publicSnapshot(raw, config);
|
|
15
19
|
const now = Date.now();
|
|
16
|
-
|
|
20
|
+
// the page's language, as in Site.astro's <html lang>; the site's own locale
|
|
21
|
+
// keeps its region for times. The browser script re-renders in it too.
|
|
22
|
+
const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
23
|
+
const locale = reqLocale && reqLocale !== siteLang(settings) ? reqLocale : siteLocale(settings);
|
|
24
|
+
const w = liveStrings(locale);
|
|
25
|
+
const v = liveView(snapshot, now, w, locale);
|
|
17
26
|
const player = !compact && display === 'player';
|
|
18
27
|
const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
19
28
|
---
|
|
@@ -26,12 +35,13 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
26
35
|
data-status={v?.status ?? 'offline'}
|
|
27
36
|
data-snapshot={JSON.stringify(snapshot)}
|
|
28
37
|
data-server-now={now}
|
|
38
|
+
data-locale={locale}
|
|
29
39
|
hidden={!v}
|
|
30
|
-
aria-label=
|
|
40
|
+
aria-label={w.video}
|
|
31
41
|
>
|
|
32
42
|
<div class="pc-live-off">
|
|
33
43
|
<i class="pc-live-off-dot" aria-hidden="true"></i>
|
|
34
|
-
<span>
|
|
44
|
+
<span>{w.notLive}</span>
|
|
35
45
|
<a data-live-watch href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer">{v?.watchLabel}</a>
|
|
36
46
|
</div>
|
|
37
47
|
<div class="pc-live-body">
|
|
@@ -52,7 +62,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
52
62
|
<h2 class="pc-live-title"><span class="pc-live-title-text" data-live-title>{v?.title}</span></h2>
|
|
53
63
|
<p class="pc-live-meta" data-live-meta hidden={!v?.meta}>{v?.meta}</p>
|
|
54
64
|
<div class="pc-live-actions">
|
|
55
|
-
<button type="button" class="pc-live-btn" data-live-load hidden>
|
|
65
|
+
<button type="button" class="pc-live-btn" data-live-load hidden>{w.playHere}</button>
|
|
56
66
|
<a class="pc-live-btn" data-live-watch href={v?.watchUrl || '#'} target="_blank" rel="noopener noreferrer">{v?.watchLabel}</a>
|
|
57
67
|
</div>
|
|
58
68
|
<small class="pc-live-privacy" data-live-privacy hidden></small>
|
|
@@ -62,6 +72,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
62
72
|
<script>
|
|
63
73
|
import { subscribeLive, currentLive, serverNow } from '../lib/live/client';
|
|
64
74
|
import { liveView, PROVIDER_NAMES } from '../lib/live/view';
|
|
75
|
+
import { fill, liveStrings } from '../lib/live/words';
|
|
65
76
|
import type { PublicLiveSnapshot } from '../lib/live/types';
|
|
66
77
|
|
|
67
78
|
const q = <T extends HTMLElement>(root: Element, sel: string) => root.querySelector<T>(sel)!;
|
|
@@ -71,6 +82,8 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
71
82
|
const cards = Array.from(document.querySelectorAll<HTMLElement>('[data-live-card]')).filter((c) => !c.dataset.initialized);
|
|
72
83
|
for (const card of cards) {
|
|
73
84
|
card.dataset.initialized = 'true';
|
|
85
|
+
const locale = card.dataset.locale || undefined;
|
|
86
|
+
const words = liveStrings(locale);
|
|
74
87
|
let shownKey = '';
|
|
75
88
|
// the links page lifts its live block to the top while live; a marker
|
|
76
89
|
// remembers where the owner placed it
|
|
@@ -110,7 +123,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
110
123
|
if (!src) return false;
|
|
111
124
|
const iframe = document.createElement('iframe');
|
|
112
125
|
iframe.src = src;
|
|
113
|
-
iframe.title = s.title ||
|
|
126
|
+
iframe.title = s.title || words.video;
|
|
114
127
|
iframe.allowFullscreen = true;
|
|
115
128
|
iframe.allow = 'fullscreen; picture-in-picture; encrypted-media';
|
|
116
129
|
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
@@ -131,7 +144,7 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
function render(s: PublicLiveSnapshot | null, now: number) {
|
|
134
|
-
const v = liveView(s, now);
|
|
147
|
+
const v = liveView(s, now, words, locale);
|
|
135
148
|
card.hidden = !v;
|
|
136
149
|
const key = s ? `${s.provider}:${s.id}:${s.status}:${s.embedId}` : '';
|
|
137
150
|
if (key !== shownKey || !eligible(s)) { if (!player.hidden) reset(); }
|
|
@@ -153,13 +166,13 @@ const layout = compact ? 'links' : player ? 'split' : config.display.style;
|
|
|
153
166
|
if (!v.thumbUrl) { img.hidden = true; img.removeAttribute('src'); }
|
|
154
167
|
else if (img.getAttribute('src') !== v.thumbUrl) img.src = v.thumbUrl;
|
|
155
168
|
const canPlay = eligible(s);
|
|
156
|
-
const name = PROVIDER_NAMES[v.provider] ||
|
|
157
|
-
poster.setAttribute('aria-label', canPlay ?
|
|
169
|
+
const name = PROVIDER_NAMES[v.provider] || words.platform;
|
|
170
|
+
poster.setAttribute('aria-label', canPlay ? fill(words.play, { title: v.title }) : `${v.watchLabel.replace(' ↗', '')}: ${v.title}`);
|
|
158
171
|
q(card, '[data-live-playicon]').hidden = !canPlay;
|
|
159
172
|
load.hidden = !canPlay || !player.hidden;
|
|
160
173
|
const privacy = q(card, '[data-live-privacy]');
|
|
161
174
|
privacy.hidden = !canPlay;
|
|
162
|
-
privacy.textContent =
|
|
175
|
+
privacy.textContent = fill(words.privacy, { provider: name });
|
|
163
176
|
card.dataset.playable = canPlay ? 'true' : 'false';
|
|
164
177
|
}
|
|
165
178
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { AdminPushState } from '../lib/push/api';
|
|
3
3
|
import type { PushAuto, PushNotification } from '../lib/push/types';
|
|
4
4
|
import '../styles/mobile-app.css';
|
|
@@ -35,7 +35,7 @@ function ago(ms: number, now: number) {
|
|
|
35
35
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by' };
|
|
38
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed' };
|
|
39
39
|
|
|
40
40
|
export default function MobileApp() {
|
|
41
41
|
const [state, setState] = useState<AdminPushState | null>(null);
|
|
@@ -261,7 +261,8 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
261
261
|
setState(r.state); setAuto(r.auto); setMsg({ ok: 'Saved.' });
|
|
262
262
|
} catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
|
|
263
263
|
};
|
|
264
|
-
const
|
|
264
|
+
const topicName = (id: string) => state.topics.find((t) => t.id === id)?.label ?? id;
|
|
265
|
+
const calendarChoices = (key: 'newEvent' | 'startingSoon' | 'eventChanges') => {
|
|
265
266
|
const list = auto[key].calendars;
|
|
266
267
|
const all = list.includes('*');
|
|
267
268
|
const set = (calendars: string[]) => setAuto({ ...auto, [key]: { ...auto[key], calendars } });
|
|
@@ -279,7 +280,7 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
279
280
|
<section className="card card-pad" aria-labelledby="ma-auto-h">
|
|
280
281
|
<header className="ma-head">
|
|
281
282
|
<h2 id="ma-auto-h">Automatic notifications</h2>
|
|
282
|
-
<p>Sent once, when something is published for the first time or a broadcast starts.
|
|
283
|
+
<p>Sent once, when something is published for the first time{state.calendarEnabled ? ', an event’s date, time or place changes' : ''} or a broadcast starts. Other edits never notify.</p>
|
|
283
284
|
</header>
|
|
284
285
|
<div className="ma-rules">
|
|
285
286
|
<div className="ma-rule">
|
|
@@ -307,6 +308,27 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
307
308
|
</select>
|
|
308
309
|
</label>
|
|
309
310
|
</div>
|
|
311
|
+
<div className="ma-rule">
|
|
312
|
+
<div className="ma-rule-head"><b>When an event changes</b><small>New date, time or place, or a cancellation, opens the event. Sent 10 minutes after the last edit (1 minute when it starts within 3 hours), so a quick fix doesn’t notify twice.</small></div>
|
|
313
|
+
{calendarChoices('eventChanges')}
|
|
314
|
+
<div className="ma-fields">
|
|
315
|
+
<div className="field ma-lead">
|
|
316
|
+
<span>Only events tagged</span>
|
|
317
|
+
<TagChips tags={auto.eventChanges.tags} suggestions={state.tags}
|
|
318
|
+
onChange={(tags) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, tags } })} />
|
|
319
|
+
</div>
|
|
320
|
+
<label className="field ma-lead">
|
|
321
|
+
<span>Send to</span>
|
|
322
|
+
<select value={auto.eventChanges.topics.join(',')}
|
|
323
|
+
onChange={(e) => setAuto({ ...auto, eventChanges: { ...auto.eventChanges, topics: e.target.value ? e.target.value.split(',') : [] } })}>
|
|
324
|
+
<option value="">The event’s topics (Events, its calendar)</option>
|
|
325
|
+
{/* several topics can only come from the API: keep them choosable */}
|
|
326
|
+
{auto.eventChanges.topics.length > 1 && <option value={auto.eventChanges.topics.join(',')}>{auto.eventChanges.topics.map(topicName).join(', ')}</option>}
|
|
327
|
+
{state.topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
|
328
|
+
</select>
|
|
329
|
+
</label>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
310
332
|
</>
|
|
311
333
|
)}
|
|
312
334
|
<div className="ma-rule">
|
|
@@ -329,6 +351,34 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
329
351
|
);
|
|
330
352
|
}
|
|
331
353
|
|
|
354
|
+
// Chips for the rule's tags; the box adds one on Enter, a comma or leaving it
|
|
355
|
+
// (suggestions: the site's tags). Empty = every event in the chosen calendars.
|
|
356
|
+
function TagChips({ tags, suggestions, onChange }: { tags: string[]; suggestions: string[]; onChange: (tags: string[]) => void }) {
|
|
357
|
+
const [draft, setDraft] = useState('');
|
|
358
|
+
const list = useId();
|
|
359
|
+
const add = () => {
|
|
360
|
+
const t = draft.replace(/,/g, ' ').trim();
|
|
361
|
+
if (t && !tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...tags, t]);
|
|
362
|
+
setDraft('');
|
|
363
|
+
};
|
|
364
|
+
return (
|
|
365
|
+
<div className="ma-tags">
|
|
366
|
+
{tags.map((t) => (
|
|
367
|
+
<span key={t} className="ma-tag">{t}<button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(tags.filter((x) => x !== t))}>✕</button></span>
|
|
368
|
+
))}
|
|
369
|
+
{tags.length < 20 && (
|
|
370
|
+
<input type="text" list={list} value={draft} maxLength={100} placeholder={tags.length ? '' : 'Any tag'} aria-label="Add a tag"
|
|
371
|
+
onChange={(e) => setDraft(e.target.value)} onBlur={add}
|
|
372
|
+
onKeyDown={(e) => {
|
|
373
|
+
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add(); }
|
|
374
|
+
if (e.key === 'Backspace' && !draft && tags.length) onChange(tags.slice(0, -1));
|
|
375
|
+
}} />
|
|
376
|
+
)}
|
|
377
|
+
<datalist id={list}>{suggestions.filter((s) => !tags.some((t) => t.toLowerCase() === s.toLowerCase())).map((s) => <option key={s} value={s} />)}</datalist>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
332
382
|
// ---- audience -----------------------------------------------------------------
|
|
333
383
|
|
|
334
384
|
function Audience({ state, topicLabel }: { state: AdminPushState; topicLabel: (id: string) => string }) {
|
package/src/layouts/Site.astro
CHANGED
|
@@ -12,6 +12,8 @@ import { announcementPath, localizeAnnouncement } from '../lib/announcements/ser
|
|
|
12
12
|
import { announcementStrings } from '../lib/announcements/words';
|
|
13
13
|
import { SEARCH } from '../lib/search-config';
|
|
14
14
|
import { searchStrings } from '../lib/search-i18n';
|
|
15
|
+
import { siteStrings } from '../lib/site-words';
|
|
16
|
+
import { liveStrings } from '../lib/live/words';
|
|
15
17
|
import { effectiveSettings, effectiveNav, resolvePreview } from '../lib/designPreview';
|
|
16
18
|
import { diffDesign } from '../lib/designDiff';
|
|
17
19
|
import { getTheme } from '../lib/theme';
|
|
@@ -71,10 +73,19 @@ const compareUrl = (live: boolean) => {
|
|
|
71
73
|
const daysLeft = preview.expiresAt ? Math.max(1, Math.ceil((preview.expiresAt - Date.now()) / 86_400_000)) : 0;
|
|
72
74
|
const versionDate = preview.versionAt ? new Date(preview.versionAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
|
|
73
75
|
const design = resolveDesign(settings);
|
|
76
|
+
// the path the visitor sees (/es/… is rewritten to the shared route by the middleware)
|
|
77
|
+
const current = (Astro.locals as { acmsPath?: string }).acmsPath ?? Astro.url.pathname;
|
|
78
|
+
// with i18n configured, the request path decides the document language; the
|
|
79
|
+
// locale setting (a date-formatting concern) stays the fallback
|
|
80
|
+
const reqLocale = I18N.enabled ? requestLocale(new URL(current, Astro.url), Astro.locals) : '';
|
|
81
|
+
const htmlLang = reqLocale || siteLang(settings);
|
|
82
|
+
// built-in words (skip link, menus, footer columns, legal links) follow it too
|
|
83
|
+
const words = siteStrings(htmlLang);
|
|
84
|
+
const liveWords = liveStrings(htmlLang);
|
|
74
85
|
const navPages = pages.filter((p) => p.status === 'published' && !p.isHome);
|
|
75
86
|
const headerNav = navItems.filter((n) => n.area === 'header');
|
|
76
87
|
const footerNav = navItems.filter((n) => n.area === 'footer').map(n => ({ label: n.label, url: n.url }));
|
|
77
|
-
if (LEGAL.enabled) for (const [url, label] of [['/privacy',
|
|
88
|
+
if (LEGAL.enabled) for (const [url, label] of [['/privacy', words.privacy], ['/terms', words.terms]]) {
|
|
78
89
|
if (!footerNav.some(n => n.url.replace(/\/$/, '') === url)) footerNav.push({ url, label });
|
|
79
90
|
}
|
|
80
91
|
const socialNav = navItems.filter((n) => n.area === 'social').map((n) => {
|
|
@@ -90,17 +101,15 @@ const isCurrent = (url: string) =>
|
|
|
90
101
|
const headerLinks: { label: string; url: string }[] = headerNav.length > 0
|
|
91
102
|
? headerNav.map((n) => ({ label: n.label, url: n.url }))
|
|
92
103
|
: [
|
|
93
|
-
{ label:
|
|
104
|
+
{ label: words.home, url: '/' },
|
|
94
105
|
...navPages.map((p) => ({ label: p.title, url: `/${p.slug}` })),
|
|
95
106
|
...Object.values(CONTENT.collections).map((c) => ({ label: c.plural, url: c.base })),
|
|
96
|
-
{ label:
|
|
107
|
+
{ label: words.links, url: '/links' },
|
|
97
108
|
];
|
|
98
109
|
const tokens = design.tokens;
|
|
99
110
|
// the visitor scheme toggle only exists when the theme follows the OS
|
|
100
111
|
const schemeToggle = tokens.scheme === 'auto';
|
|
101
112
|
|
|
102
|
-
// the path the visitor sees (/es/… is rewritten to the shared route by the middleware)
|
|
103
|
-
const current = (Astro.locals as { acmsPath?: string }).acmsPath ?? Astro.url.pathname;
|
|
104
113
|
const fullTitle = exactTitle && title ? title : title ? `${title} · ${settings.siteTitle}` : settings.siteTitle;
|
|
105
114
|
// the columns footer already shows the tagline beside the brand, so its
|
|
106
115
|
// default bottom line is just the copyright
|
|
@@ -122,14 +131,10 @@ const annKey = showAnnouncement
|
|
|
122
131
|
: '';
|
|
123
132
|
const canonical = origin + canonicalPath(current);
|
|
124
133
|
const metaDescription = description || settings.tagline || undefined;
|
|
125
|
-
// with i18n configured, the request path decides the document language; the
|
|
126
|
-
// locale setting (a date-formatting concern) stays the fallback
|
|
127
|
-
const reqLocale = I18N.enabled ? requestLocale(new URL(current, Astro.url), Astro.locals) : '';
|
|
128
134
|
// same page, other language; internal links stay in the visitor's language
|
|
129
135
|
const barePath = stripLocale(current);
|
|
130
136
|
const inLang = reqLocale && reqLocale !== I18N.defaultLocale ? reqLocale : '';
|
|
131
137
|
const lnk = (url: string) => (inLang && /^\/(?!\/|media\/|rss\.xml|sitemap|admin|api|_)/.test(url) ? localePath(url, inLang) : url);
|
|
132
|
-
const htmlLang = reqLocale || siteLang(settings);
|
|
133
138
|
// pinned announcements (Admin → Announcements) take the bar's place while
|
|
134
139
|
// they're up, urgent first; each one is closed on its own (remembered per id)
|
|
135
140
|
const annWords = announcementStrings(htmlLang);
|
|
@@ -152,7 +157,7 @@ const searchAction = reqLocale && reqLocale !== I18N.defaultLocale ? `/${reqLoca
|
|
|
152
157
|
// theme asks for it (footer: simple).
|
|
153
158
|
const readLinks = [
|
|
154
159
|
...Object.values(CONTENT.collections).map((c) => ({ label: c.plural, url: c.base })),
|
|
155
|
-
...(SEARCH.enabled ? [{ label:
|
|
160
|
+
...(SEARCH.enabled ? [{ label: searchT.label, url: searchAction }] : []),
|
|
156
161
|
{ label: 'RSS', url: '/rss.xml' },
|
|
157
162
|
];
|
|
158
163
|
// a theme's templates/header.astro / footer.astro get this instead of core markup
|
|
@@ -259,10 +264,10 @@ const homeJsonLd =
|
|
|
259
264
|
<body>
|
|
260
265
|
{liveSignals?.bar && (
|
|
261
266
|
<div class="pc-live-bar" data-live-bar hidden role="status">
|
|
262
|
-
<span class="pc-live-mark">
|
|
267
|
+
<span class="pc-live-mark">{liveWords.bar}</span>
|
|
263
268
|
<span class="pc-live-bar-title" data-live-bar-title></span>
|
|
264
|
-
<a data-live-bar-watch href="#">
|
|
265
|
-
<button type="button" class="pc-live-x" aria-label=
|
|
269
|
+
<a data-live-bar-watch href="#">{liveWords.watch}</a>
|
|
270
|
+
<button type="button" class="pc-live-x" aria-label={liveWords.dismissBar}>✕</button>
|
|
266
271
|
</div>
|
|
267
272
|
)}
|
|
268
273
|
{pinnedBars.map((b) => (
|
|
@@ -297,7 +302,7 @@ const homeJsonLd =
|
|
|
297
302
|
<div class="announcement-bar" data-ann-key={ann.dismissible ? annKey : undefined} hidden={ann.dismissible || undefined}>
|
|
298
303
|
{annLink ? <a href={annLink}>{ann.text}</a> : <span>{ann.text}</span>}
|
|
299
304
|
{ann.dismissible && (
|
|
300
|
-
<button type="button" class="announcement-x" aria-label=
|
|
305
|
+
<button type="button" class="announcement-x" aria-label={annWords.dismiss}>✕</button>
|
|
301
306
|
)}
|
|
302
307
|
</div>
|
|
303
308
|
)}
|
|
@@ -317,7 +322,7 @@ const homeJsonLd =
|
|
|
317
322
|
})();
|
|
318
323
|
</script>
|
|
319
324
|
)}
|
|
320
|
-
{!bare && <a class="skip-link" href="#main">
|
|
325
|
+
{!bare && <a class="skip-link" href="#main">{words.skip}</a>}
|
|
321
326
|
{!bare && HeaderTpl && <HeaderTpl site={chrome} />}
|
|
322
327
|
{!bare && !HeaderTpl && (
|
|
323
328
|
<header class="site-header" data-style={tokens.header}>
|
|
@@ -325,7 +330,7 @@ const homeJsonLd =
|
|
|
325
330
|
<a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
|
|
326
331
|
<SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} />
|
|
327
332
|
</a>
|
|
328
|
-
<nav class="site-nav" aria-label=
|
|
333
|
+
<nav class="site-nav" aria-label={words.main}>
|
|
329
334
|
{headerLinks.map((n) => (
|
|
330
335
|
<a
|
|
331
336
|
href={lnk(n.url)}
|
|
@@ -336,7 +341,7 @@ const homeJsonLd =
|
|
|
336
341
|
))}
|
|
337
342
|
</nav>
|
|
338
343
|
<div class="site-tools">
|
|
339
|
-
{liveSignals?.header && <a class="site-live" data-live-header href="#" hidden>
|
|
344
|
+
{liveSignals?.header && <a class="site-live" data-live-header href="#" hidden>{liveWords.marker}</a>}
|
|
340
345
|
{showSearch && (
|
|
341
346
|
<a class="pc-search-trigger" href={searchAction} data-search-open aria-label={searchT.label}>
|
|
342
347
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
|
|
@@ -345,7 +350,7 @@ const homeJsonLd =
|
|
|
345
350
|
</a>
|
|
346
351
|
)}
|
|
347
352
|
{I18N.enabled && (
|
|
348
|
-
<span class="lang-switch" role="group" aria-label=
|
|
353
|
+
<span class="lang-switch" role="group" aria-label={words.language}>
|
|
349
354
|
{I18N.locales.map((l) => (
|
|
350
355
|
<a
|
|
351
356
|
href={localePath(barePath, l)}
|
|
@@ -357,12 +362,12 @@ const homeJsonLd =
|
|
|
357
362
|
</span>
|
|
358
363
|
)}
|
|
359
364
|
{schemeToggle && (
|
|
360
|
-
<button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label=
|
|
365
|
+
<button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label={words.scheme}>
|
|
361
366
|
<svg class="i-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" /></svg>
|
|
362
367
|
<svg class="i-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" /></svg>
|
|
363
368
|
</button>
|
|
364
369
|
)}
|
|
365
|
-
<button type="button" class="icon-btn menu-btn" data-menu-open aria-label=
|
|
370
|
+
<button type="button" class="icon-btn menu-btn" data-menu-open aria-label={words.openMenu} aria-haspopup="dialog">
|
|
366
371
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 8h16M4 16h16" /></svg>
|
|
367
372
|
</button>
|
|
368
373
|
</div>
|
|
@@ -370,9 +375,9 @@ const homeJsonLd =
|
|
|
370
375
|
</header>
|
|
371
376
|
)}
|
|
372
377
|
{!bare && (
|
|
373
|
-
<dialog class="site-menu" aria-label=
|
|
378
|
+
<dialog class="site-menu" aria-label={words.menu}>
|
|
374
379
|
<div class="site-menu-grab" aria-hidden="true"></div>
|
|
375
|
-
<nav aria-label=
|
|
380
|
+
<nav aria-label={words.main}>
|
|
376
381
|
{headerLinks.map((n) => (
|
|
377
382
|
<a
|
|
378
383
|
href={lnk(n.url)}
|
|
@@ -384,7 +389,7 @@ const homeJsonLd =
|
|
|
384
389
|
</nav>
|
|
385
390
|
<div class="site-menu-foot">
|
|
386
391
|
{I18N.enabled ? (
|
|
387
|
-
<span class="lang-switch" role="group" aria-label=
|
|
392
|
+
<span class="lang-switch" role="group" aria-label={words.language}>
|
|
388
393
|
{I18N.locales.map((l) => (
|
|
389
394
|
<a href={localePath(barePath, l)} lang={l} class:list={[{ on: l === (reqLocale || I18N.defaultLocale) }]}>{l.toUpperCase()}</a>
|
|
390
395
|
))}
|
|
@@ -392,12 +397,12 @@ const homeJsonLd =
|
|
|
392
397
|
) : <span />}
|
|
393
398
|
<span class="site-menu-actions">
|
|
394
399
|
{schemeToggle && (
|
|
395
|
-
<button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label=
|
|
400
|
+
<button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label={words.scheme}>
|
|
396
401
|
<svg class="i-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" /></svg>
|
|
397
402
|
<svg class="i-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" /></svg>
|
|
398
403
|
</button>
|
|
399
404
|
)}
|
|
400
|
-
<button type="button" class="icon-btn" data-menu-close aria-label=
|
|
405
|
+
<button type="button" class="icon-btn" data-menu-close aria-label={words.closeMenu}>
|
|
401
406
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
|
402
407
|
</button>
|
|
403
408
|
</span>
|
|
@@ -405,7 +410,7 @@ const homeJsonLd =
|
|
|
405
410
|
</dialog>
|
|
406
411
|
)}
|
|
407
412
|
{!bare && (
|
|
408
|
-
<script is:inline>
|
|
413
|
+
<script is:inline define:vars={{ copyLabel: words.copy, copiedLabel: words.copied, closeLabel: words.close }}>
|
|
409
414
|
(() => {
|
|
410
415
|
// phone menu: a bottom sheet on <dialog> (focus trap + Esc for free)
|
|
411
416
|
const menu = document.querySelector('dialog.site-menu');
|
|
@@ -428,13 +433,13 @@ const homeJsonLd =
|
|
|
428
433
|
const b = document.createElement('button');
|
|
429
434
|
b.type = 'button';
|
|
430
435
|
b.className = 'code-copy';
|
|
431
|
-
b.textContent =
|
|
436
|
+
b.textContent = copyLabel;
|
|
432
437
|
b.addEventListener('click', async () => {
|
|
433
438
|
try {
|
|
434
439
|
await navigator.clipboard.writeText((pre.querySelector('code') || pre).innerText);
|
|
435
|
-
b.textContent =
|
|
440
|
+
b.textContent = copiedLabel;
|
|
436
441
|
b.classList.add('is-done');
|
|
437
|
-
setTimeout(() => { b.textContent =
|
|
442
|
+
setTimeout(() => { b.textContent = copyLabel; b.classList.remove('is-done'); }, 1400);
|
|
438
443
|
} catch {}
|
|
439
444
|
});
|
|
440
445
|
pre.append(b);
|
|
@@ -448,7 +453,8 @@ const homeJsonLd =
|
|
|
448
453
|
if (!box) {
|
|
449
454
|
box = document.createElement('dialog');
|
|
450
455
|
box.className = 'pc-lightbox';
|
|
451
|
-
box.innerHTML = '<img alt=""><button type="button"
|
|
456
|
+
box.innerHTML = '<img alt=""><button type="button">\u2715</button>';
|
|
457
|
+
box.querySelector('button').setAttribute('aria-label', closeLabel);
|
|
452
458
|
box.addEventListener('click', () => box.close());
|
|
453
459
|
document.body.append(box);
|
|
454
460
|
}
|
|
@@ -727,7 +733,7 @@ const homeJsonLd =
|
|
|
727
733
|
<a class="brand" href="/"><SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} /></a>
|
|
728
734
|
{settings.tagline && <p class="footer-blurb">{settings.tagline}</p>}
|
|
729
735
|
{socialNav.length > 0 && (
|
|
730
|
-
<nav class="social-nav" aria-label=
|
|
736
|
+
<nav class="social-nav" aria-label={words.social}>
|
|
731
737
|
{socialNav.map((n) => (
|
|
732
738
|
<a href={lnk(n.url)} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} title={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
|
|
733
739
|
{n.icon ? <Fragment set:html={n.icon} /> : n.label}
|
|
@@ -736,13 +742,13 @@ const homeJsonLd =
|
|
|
736
742
|
</nav>
|
|
737
743
|
)}
|
|
738
744
|
</div>
|
|
739
|
-
<nav class="footer-col" aria-label=
|
|
740
|
-
<h2>
|
|
745
|
+
<nav class="footer-col" aria-label={words.read}>
|
|
746
|
+
<h2>{words.read}</h2>
|
|
741
747
|
{readLinks.map((n) => <a href={lnk(n.url)}>{n.label}</a>)}
|
|
742
748
|
</nav>
|
|
743
749
|
{footerNav.length > 0 && (
|
|
744
|
-
<nav class="footer-col" aria-label=
|
|
745
|
-
<h2>
|
|
750
|
+
<nav class="footer-col" aria-label={words.site}>
|
|
751
|
+
<h2>{words.site}</h2>
|
|
746
752
|
{footerNav.map((n) => (
|
|
747
753
|
<a href={lnk(n.url)} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
|
|
748
754
|
))}
|
|
@@ -759,7 +765,7 @@ const homeJsonLd =
|
|
|
759
765
|
</nav>
|
|
760
766
|
)}
|
|
761
767
|
{socialNav.length > 0 && (
|
|
762
|
-
<nav class="social-nav" aria-label=
|
|
768
|
+
<nav class="social-nav" aria-label={words.social}>
|
|
763
769
|
{socialNav.map((n) => (
|
|
764
770
|
<a href={lnk(n.url)} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
|
|
765
771
|
{n.icon ? <Fragment set:html={n.icon} /> : n.label}
|
|
@@ -777,9 +783,9 @@ const homeJsonLd =
|
|
|
777
783
|
<div class="pc-live-dock" data-live-dock hidden>
|
|
778
784
|
<a href="#">
|
|
779
785
|
<span class="pc-live-dock-thumb"><img alt="" decoding="async" hidden /></span>
|
|
780
|
-
<span><span class="pc-live-mark">
|
|
786
|
+
<span><span class="pc-live-mark">{liveWords.marker}</span><b data-live-dock-title></b></span>
|
|
781
787
|
</a>
|
|
782
|
-
<button type="button" class="pc-live-x" aria-label=
|
|
788
|
+
<button type="button" class="pc-live-x" aria-label={liveWords.dismissDock}>✕</button>
|
|
783
789
|
</div>
|
|
784
790
|
)}
|
|
785
791
|
<script>
|
|
@@ -8,14 +8,14 @@ import { siteOrigin } from '../seo';
|
|
|
8
8
|
import { pageQuery, queryParams } from '../mobile/http';
|
|
9
9
|
import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
|
|
10
10
|
import { PUSH } from '../push/config';
|
|
11
|
-
import { deviceLocale, knownTopics, pushTopics, siteOriginOf } from '../push/service';
|
|
11
|
+
import { deviceLocale, knownTopics, pushTopics, siteOriginOf, sitePath } from '../push/service';
|
|
12
12
|
import { linkTargets } from '../push/api';
|
|
13
13
|
import { ANNOUNCEMENTS } from './config';
|
|
14
14
|
import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
|
|
15
15
|
import { announcementStrings } from './words';
|
|
16
16
|
import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
|
|
17
17
|
|
|
18
|
-
const DAY =
|
|
18
|
+
const HOUR = 3_600_000, DAY = 24 * HOUR;
|
|
19
19
|
export function requireAnnouncements() {
|
|
20
20
|
if (!ANNOUNCEMENTS.enabled) throw new ApiError(404, 'Announcements are off on this site.');
|
|
21
21
|
}
|
|
@@ -70,18 +70,20 @@ export function defaultAudience(known: Set<string>): Record<AnnouncementKind, st
|
|
|
70
70
|
export async function adminAnnouncementsState(repo: ContentRepository, url: URL) {
|
|
71
71
|
const now = Date.now();
|
|
72
72
|
const settings = await repo.getSettings();
|
|
73
|
-
const [announcements, topics, known, stats, links, sentThisWeek] = await Promise.all([
|
|
73
|
+
const [announcements, topics, known, stats, links, sentThisWeek, changes] = await Promise.all([
|
|
74
74
|
repo.announcements.list(),
|
|
75
75
|
PUSH.enabled ? pushTopics(repo) : Promise.resolve([]),
|
|
76
76
|
PUSH.enabled ? knownTopics(repo) : Promise.resolve(new Set<string>()),
|
|
77
77
|
PUSH.enabled ? repo.push.stats(now) : Promise.resolve(null),
|
|
78
78
|
linkTargets(repo),
|
|
79
79
|
PUSH.enabled ? repo.push.countSent(['manual', 'announcement'], now - 7 * DAY) : Promise.resolve(0),
|
|
80
|
+
PUSH.enabled ? repo.push.sentSince('change', now - 2 * HOUR) : Promise.resolve([]),
|
|
80
81
|
]);
|
|
81
82
|
const main = I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en');
|
|
83
|
+
const origin = siteOriginOf(settings, url.origin);
|
|
82
84
|
return {
|
|
83
85
|
now,
|
|
84
|
-
site: { name: settings.siteTitle, icon: settings.siteFavicon, origin
|
|
86
|
+
site: { name: settings.siteTitle, icon: settings.siteFavicon, origin, timezone: settings.timezone || 'UTC' },
|
|
85
87
|
languages: { main: { code: main, name: localeName(main) }, extra: extraLanguages().map((code) => ({ code, name: localeName(code) })) },
|
|
86
88
|
kinds: ANNOUNCEMENT_KINDS.map((id) => ({ id, label: announcementStrings('en').kinds[id] })),
|
|
87
89
|
limits: LIMITS,
|
|
@@ -98,6 +100,8 @@ export async function adminAnnouncementsState(repo: ContentRepository, url: URL)
|
|
|
98
100
|
// the push audience each kind starts with in the composer (['*'] = everyone)
|
|
99
101
|
defaultAudience: defaultAudience(known),
|
|
100
102
|
links,
|
|
103
|
+
// automatic event-change notices of the last 2 hours: a push whose button opens the same event repeats one
|
|
104
|
+
recentChanges: changes.map((n) => ({ path: sitePath(n.url, origin), at: n.createdAt, title: n.title })).filter((c) => c.path),
|
|
101
105
|
announcements,
|
|
102
106
|
};
|
|
103
107
|
}
|
|
@@ -68,6 +68,10 @@ export class AnnouncementStore {
|
|
|
68
68
|
async due(now: number, limit = 10): Promise<Announcement[]> {
|
|
69
69
|
return (await this.all(`SELECT * FROM announcements WHERE status='scheduled' AND COALESCE(publish_at,0)<=? ORDER BY publish_at LIMIT ?`, [now, limit])).map(announcement);
|
|
70
70
|
}
|
|
71
|
+
/** Ones that went up with a push since `since` (an event-change notice about the same event is skipped). */
|
|
72
|
+
async pushedSince(since: number): Promise<Announcement[]> {
|
|
73
|
+
return (await this.all(`SELECT * FROM announcements WHERE notification_id IS NOT NULL AND published_at>=? ORDER BY published_at DESC LIMIT 50`, [since])).map(announcement);
|
|
74
|
+
}
|
|
71
75
|
/** Published ones past their end: they come down. */
|
|
72
76
|
async endExpired(now: number): Promise<number> {
|
|
73
77
|
return (await this.all(`UPDATE announcements SET status='ended', updated_at=? WHERE status='published' AND show_until IS NOT NULL AND show_until<=? RETURNING id`, [now, now])).length;
|