@acms-subir/core 0.3.33 → 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 +22 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +2 -2
- package/integration.mjs +2 -1
- 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/cache/policy.ts +11 -3
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/legal/app-privacy.ts +1 -1
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +5 -3
- package/src/lib/legal/text-es.ts +5 -3
- 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
|
@@ -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;
|
package/src/lib/cache/policy.ts
CHANGED
|
@@ -8,6 +8,12 @@ import { I18N } from '../i18n';
|
|
|
8
8
|
|
|
9
9
|
export const PRIVATE_CACHE_CONTROL = 'private, no-store';
|
|
10
10
|
export const publicCacheControl = () => `public, s-maxage=${CACHE.maxAge}, stale-while-revalidate=${CACHE.staleWhileRevalidate}`;
|
|
11
|
+
// The public app API: browsers (Expo web, web apps) revalidate by ETag every
|
|
12
|
+
// time, since stale-while-revalidate would hand them an old /site without a
|
|
13
|
+
// feature the app just got. CDNs that read CDN-Cache-Control (RFC 9213, e.g.
|
|
14
|
+
// Cloudflare) still serve it stale while they refresh, like pages; others fall
|
|
15
|
+
// back to s-maxage.
|
|
16
|
+
export const apiCacheControl = () => `public, max-age=0, s-maxage=${CACHE.maxAge}`;
|
|
11
17
|
// publicResponse's own header: browsers/apps always revalidate by ETag. The
|
|
12
18
|
// policy upgrades it with s-maxage so a CDN can share it.
|
|
13
19
|
export const API_REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
@@ -64,12 +70,13 @@ export function applyCachePolicy(request: Request, response: Response, pathname
|
|
|
64
70
|
if (!CACHE.enabled) return response;
|
|
65
71
|
if (OWN_HEADERS.test(pathname)) return response;
|
|
66
72
|
const current = response.headers.get('cache-control');
|
|
67
|
-
const set = (value: string) => {
|
|
68
|
-
|
|
73
|
+
const set = (value: string, cdn?: string) => {
|
|
74
|
+
const write = (h: Headers) => { h.set('Cache-Control', value); if (cdn) h.set('CDN-Cache-Control', cdn); };
|
|
75
|
+
try { write(response.headers); return response; }
|
|
69
76
|
catch {
|
|
70
77
|
// immutable headers (a proxied/fetched response): copy it
|
|
71
78
|
const copy = new Response(response.body, response);
|
|
72
|
-
copy.headers
|
|
79
|
+
write(copy.headers);
|
|
73
80
|
return copy;
|
|
74
81
|
}
|
|
75
82
|
};
|
|
@@ -85,5 +92,6 @@ export function applyCachePolicy(request: Request, response: Response, pathname
|
|
|
85
92
|
warnCookie(pathname);
|
|
86
93
|
return set(PRIVATE_CACHE_CONTROL);
|
|
87
94
|
}
|
|
95
|
+
if (pathname.startsWith('/api/v1/')) return set(apiCacheControl(), publicCacheControl());
|
|
88
96
|
return set(publicCacheControl());
|
|
89
97
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Side effects when an event changes live: a 301 when a published event's
|
|
2
|
-
// slug moves,
|
|
2
|
+
// slug moves, a queued "event changed" app notice when its schedule or place
|
|
3
|
+
// moves, and the matching webhook (event.published / event.updated /
|
|
3
4
|
// event.unpublished). Shared by the editor actions, the list's bulk actions
|
|
4
5
|
// and the agent tools so every path behaves the same.
|
|
5
6
|
import type { ContentRepository } from '../repo/types';
|
|
6
7
|
import type { CalEvent } from '../schema';
|
|
7
8
|
import { emitWebhook } from '../webhooks';
|
|
9
|
+
import { queueEventChange } from '../push/service';
|
|
8
10
|
import { eventToJson } from './api';
|
|
9
11
|
import { eventUrl } from './config';
|
|
10
12
|
|
|
@@ -18,6 +20,8 @@ export async function eventChanged(
|
|
|
18
20
|
await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
|
|
19
21
|
opts.onRedirect?.();
|
|
20
22
|
}
|
|
23
|
+
// the scheduler sends it once the edits settle, if the rule still applies
|
|
24
|
+
await queueEventChange(repo, before, after).catch((e) => console.error('acms push: event change not queued', e));
|
|
21
25
|
let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
|
|
22
26
|
if (before.status !== 'published' && after.status === 'published') type = 'event.published';
|
|
23
27
|
else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
|
|
@@ -29,7 +29,7 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
|
|
|
29
29
|
google: 'Personal info → Email address. Collected, not shared. Purpose: App functionality, Account management. Optional.',
|
|
30
30
|
});
|
|
31
31
|
if (f.push.enabled) rows.push({
|
|
32
|
-
data: 'Push token, platform, language and
|
|
32
|
+
data: 'Push token, platform, language, chosen topics and Android notification channels', when: 'When someone turns on notifications',
|
|
33
33
|
apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
34
34
|
google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
|
|
35
35
|
});
|
package/src/lib/legal/facts.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
3
3
|
import type { Settings } from '../schema';
|
|
4
4
|
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
5
5
|
import { PUSH } from '../push/config';
|
|
6
|
+
import { ANNOUNCEMENTS } from '../announcements/config';
|
|
6
7
|
import { AUTH } from '../auth/config';
|
|
7
8
|
import { EDITOR } from '../editor/config';
|
|
8
9
|
import { SITE_ROLES } from '../roles';
|
|
@@ -39,6 +40,8 @@ export interface LegalFacts {
|
|
|
39
40
|
/** maps provider name, or '' */
|
|
40
41
|
maps: string;
|
|
41
42
|
live: boolean;
|
|
43
|
+
/** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
|
|
44
|
+
announcements: boolean;
|
|
42
45
|
analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
|
|
43
46
|
ai: boolean;
|
|
44
47
|
offsiteBackups: boolean;
|
|
@@ -79,6 +82,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
|
|
|
79
82
|
email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
|
|
80
83
|
maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
|
|
81
84
|
live: settings.live.mode !== 'off',
|
|
85
|
+
announcements: ANNOUNCEMENTS.enabled,
|
|
82
86
|
analytics: {
|
|
83
87
|
umami: a.umami.enabled && !!a.umami.websiteId,
|
|
84
88
|
replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -52,8 +52,8 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
52
52
|
'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
|
|
53
53
|
] });
|
|
54
54
|
if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
|
|
55
|
-
'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language
|
|
56
|
-
`You choose the topics in the app and can change them at any time; we never add a topic without asking you. To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
|
|
55
|
+
'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
|
|
56
|
+
`You choose the topics in the app and can change them at any time; we never add a topic without asking you. ${f.announcements ? 'Urgent announcements, such as cancellations, go to every device with notifications turned on, whatever topics it chose.' : 'Important notices, such as cancellations, may go to every device with notifications turned on, whatever topics it chose.'} To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
|
|
57
57
|
] });
|
|
58
58
|
if (f.email) s.push({ id: 'email', title: 'Email', text: [
|
|
59
59
|
`We send email through ${f.email}, which processes your email address and the message. We keep a delivery record (recipient and status) for 30 days, and keep addresses that bounce or report our email as spam on a list so we stop writing to them.`,
|
|
@@ -79,7 +79,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
79
79
|
] });
|
|
80
80
|
s.push({ id: 'cookies', title: 'Cookies and storage on your device', text: [
|
|
81
81
|
`${f.auth === 'clerk' ? 'Clerk’s sign-in cookies' : 'A sign-in cookie (up to seven days)'} for people who sign in; a preview cookie only for staff previews and design share links (up to 30 days); and, in your browser, your light or dark mode choice, the announcements you close and, for one visit, the live video notices you close.${pixels ? ` ${pixelNames} ${f.analytics.meta && f.analytics.tiktok ? 'set their' : 'sets its'} own advertising cookies.` : ' We don’t use cookies for advertising.'}`,
|
|
82
|
-
...(app ? [
|
|
82
|
+
...(app ? [f.announcements
|
|
83
|
+
? 'Our app may keep the content it last loaded on your phone so it works offline, and remembers there your notification choice and which announcements you’ve read or closed. That stays on your phone and is never sent to us.'
|
|
84
|
+
: 'Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
|
|
83
85
|
] });
|
|
84
86
|
s.push({ id: 'sharing', title: 'Who receives your information', text: [
|
|
85
87
|
`We don’t sell your personal information${pixels ? '' : ' or share it for advertising'}. These service providers process it on our behalf and only for the purposes above: ${list(processors(f))}. They must protect it at least as this notice describes. Some of them may store it outside your country, including in the United States.`,
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -53,8 +53,8 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
53
53
|
'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
|
|
54
54
|
] });
|
|
55
55
|
if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
|
|
56
|
-
'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma
|
|
57
|
-
`Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
|
|
56
|
+
'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
|
|
57
|
+
`Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. ${f.announcements ? 'Los anuncios urgentes, como una cancelación, llegan a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.' : 'Los avisos importantes, como una cancelación, pueden llegar a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.'} Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
|
|
58
58
|
] });
|
|
59
59
|
if (f.email) s.push({ id: 'email', title: 'Correo electrónico', text: [
|
|
60
60
|
`Enviamos correos a través de ${f.email}, que trata su dirección de correo y el mensaje. Guardamos un registro de entrega (destinatario y estado) durante 30 días, y mantenemos en una lista las direcciones que rebotan o marcan nuestros correos como spam para dejar de escribirles.`,
|
|
@@ -80,7 +80,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
80
80
|
] });
|
|
81
81
|
s.push({ id: 'cookies', title: 'Cookies y almacenamiento en su dispositivo', text: [
|
|
82
82
|
`${f.auth === 'clerk' ? 'Las cookies de inicio de sesión de Clerk' : 'Una cookie de inicio de sesión (hasta siete días)'} para quienes inician sesión; una cookie de vista previa solo para las vistas previas del personal y los enlaces para compartir diseños (hasta 30 días); y, en su navegador, su preferencia de modo claro u oscuro, los anuncios que cierra y, durante una visita, los avisos de video en vivo que cierra.${pixels ? ` ${pixelNames} ${both ? 'guardan sus propias' : 'guarda sus propias'} cookies de publicidad.` : ' No usamos cookies con fines publicitarios.'}`,
|
|
83
|
-
...(app ? [
|
|
83
|
+
...(app ? [f.announcements
|
|
84
|
+
? 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones y qué anuncios leyó o cerró. Eso se queda en su teléfono y nunca se nos envía.'
|
|
85
|
+
: 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
|
|
84
86
|
] });
|
|
85
87
|
s.push({ id: 'sharing', title: 'Quién recibe sus datos', text: [
|
|
86
88
|
`No vendemos sus datos personales${pixels ? '' : ' ni los compartimos con fines publicitarios'}. Estos proveedores los tratan por cuenta nuestra y solo para los fines descritos: ${list(processors(f))}. Deben protegerlos al menos como lo describe este aviso. Algunos pueden guardarlos fuera de su país, incluido Estados Unidos.`,
|
package/src/lib/live/view.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { PublicLiveSnapshot } from './types';
|
|
2
|
+
import { fill, liveStrings, type LiveStrings } from './words';
|
|
2
3
|
|
|
3
4
|
// Display text for a live snapshot. Pure and DOM-free: LiveCard renders it on
|
|
4
5
|
// the server and the browser re-applies it on every status change, so both
|
|
5
|
-
// always agree.
|
|
6
|
+
// always agree. Both pass the page's words and language (English by default).
|
|
6
7
|
|
|
7
8
|
export const PROVIDER_NAMES: Record<string, string> = { youtube: 'YouTube', twitch: 'Twitch', facebook: 'Facebook', other: '' };
|
|
8
9
|
|
|
@@ -23,6 +24,7 @@ export interface LiveView {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
|
|
27
|
+
const EN = liveStrings('en');
|
|
26
28
|
|
|
27
29
|
export function compactCount(n: number): string {
|
|
28
30
|
if (n < 1000) return String(n);
|
|
@@ -30,49 +32,51 @@ export function compactCount(n: number): string {
|
|
|
30
32
|
return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${unit}`;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
export function ago(ms: number): string {
|
|
34
|
-
if (ms < MIN) return
|
|
35
|
-
if (ms < HOUR) return `${Math.floor(ms / MIN)} min
|
|
35
|
+
export function ago(ms: number, w: LiveStrings = EN): string {
|
|
36
|
+
if (ms < MIN) return w.justNow;
|
|
37
|
+
if (ms < HOUR) return fill(w.ago, { time: `${Math.floor(ms / MIN)} min` });
|
|
36
38
|
const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
|
|
37
|
-
return m ? `${h} h ${m} min
|
|
39
|
+
return fill(w.ago, { time: m ? `${h} h ${m} min` : `${h} h` });
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
export function until(ms: number): string {
|
|
41
|
-
if (ms < MIN) return
|
|
42
|
-
if (ms < HOUR) return
|
|
43
|
-
if (ms < DAY) return
|
|
42
|
+
export function until(ms: number, w: LiveStrings = EN): string {
|
|
43
|
+
if (ms < MIN) return w.startingNow;
|
|
44
|
+
if (ms < HOUR) return fill(w.in, { time: `${Math.ceil(ms / MIN)} min` });
|
|
45
|
+
if (ms < DAY) return fill(w.in, { time: `${Math.round(ms / HOUR)} h` });
|
|
44
46
|
const d = Math.round(ms / DAY);
|
|
45
|
-
return
|
|
47
|
+
return fill(w.in, { time: fill(d === 1 ? w.day : w.days, { n: String(d) }) });
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// a bad language tag formats like the runtime default rather than failing the page
|
|
51
|
+
export function when(ts: number, now: number, locale?: string): string {
|
|
52
|
+
const format = (o: Intl.DateTimeFormatOptions) => {
|
|
53
|
+
try { return new Intl.DateTimeFormat(locale, o).format(ts); } catch { return new Intl.DateTimeFormat(undefined, o).format(ts); }
|
|
54
|
+
};
|
|
55
|
+
const time = format({ hour: 'numeric', minute: '2-digit' });
|
|
56
|
+
return `${format(ts - now < 6 * DAY ? { weekday: 'short' } : { month: 'short', day: 'numeric' })} ${time}`;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
export function liveView(s: PublicLiveSnapshot | null, now: number): LiveView | null {
|
|
59
|
+
export function liveView(s: PublicLiveSnapshot | null, now: number, w: LiveStrings = EN, locale?: string): LiveView | null {
|
|
56
60
|
if (!s || (s.status !== 'live' && s.status !== 'upcoming' && s.status !== 'offline')) return null;
|
|
57
61
|
const name = PROVIDER_NAMES[s.provider] ?? '';
|
|
58
62
|
const channel = s.channel?.trim() ?? '';
|
|
59
63
|
const base = { status: s.status, provider: s.provider, title: s.title, watchUrl: s.watchUrl, thumbUrl: s.thumbUrl };
|
|
60
64
|
if (s.status === 'offline') {
|
|
61
|
-
return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel:
|
|
65
|
+
return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: w.watchChannel };
|
|
62
66
|
}
|
|
63
|
-
const watchLabel = name ?
|
|
67
|
+
const watchLabel = name ? fill(w.watchOn, { provider: name }) : w.watchNow;
|
|
64
68
|
if (s.status === 'upcoming') {
|
|
65
|
-
const start = s.startedAt > 0 ? `${when(s.startedAt, now)} · ${until(s.startedAt - now)}` : '';
|
|
69
|
+
const start = s.startedAt > 0 ? `${when(s.startedAt, now, locale)} · ${until(s.startedAt - now, w)}` : '';
|
|
66
70
|
return {
|
|
67
|
-
...base, tag: start ||
|
|
68
|
-
meta: [channel, name &&
|
|
71
|
+
...base, tag: start || w.upcoming, kicker: name ? fill(w.upcomingOn, { provider: name.toUpperCase() }) : w.upcoming,
|
|
72
|
+
meta: [channel, name && w.scheduled].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? fill(w.openOn, { provider: name }) : w.open,
|
|
69
73
|
};
|
|
70
74
|
}
|
|
71
|
-
const started = s.startedAt > 0 && s.startedAt <= now ?
|
|
75
|
+
const started = s.startedAt > 0 && s.startedAt <= now ? fill(w.started, { time: ago(now - s.startedAt, w) }) : '';
|
|
72
76
|
return {
|
|
73
|
-
...base, tag:
|
|
77
|
+
...base, tag: w.tag, kicker: name ? fill(w.liveOn, { provider: name.toUpperCase() }) : w.liveNow,
|
|
74
78
|
meta: [channel, started].filter(Boolean).join(' · '),
|
|
75
|
-
viewers: typeof s.viewers === 'number' ?
|
|
79
|
+
viewers: typeof s.viewers === 'number' ? fill(w.watching, { count: compactCount(s.viewers) }) : '',
|
|
76
80
|
watchLabel,
|
|
77
81
|
};
|
|
78
82
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Words the website's live surfaces show: the live card (view.ts, LiveCard)
|
|
2
|
+
// and the site-wide bar, header marker and dock (Site.astro). Plain strings
|
|
3
|
+
// with {provider}, {time}, {count}, {n} and {title} slots, so LiveCard can
|
|
4
|
+
// hand them to its browser script as JSON. Spanish matches the app's words
|
|
5
|
+
// (packages/native strings.ts) where they overlap. Other languages fall back
|
|
6
|
+
// to English.
|
|
7
|
+
export interface LiveStrings {
|
|
8
|
+
/** poster tag while live */
|
|
9
|
+
tag: string;
|
|
10
|
+
/** above the title: without a provider name, then with it (upper-cased) */
|
|
11
|
+
liveNow: string;
|
|
12
|
+
liveOn: string;
|
|
13
|
+
upcoming: string;
|
|
14
|
+
upcomingOn: string;
|
|
15
|
+
watchOn: string;
|
|
16
|
+
watchNow: string;
|
|
17
|
+
openOn: string;
|
|
18
|
+
open: string;
|
|
19
|
+
watchChannel: string;
|
|
20
|
+
/** after the channel name of an upcoming broadcast */
|
|
21
|
+
scheduled: string;
|
|
22
|
+
/** after the channel name while live: "started {time}" with {time} from `ago` */
|
|
23
|
+
started: string;
|
|
24
|
+
justNow: string;
|
|
25
|
+
ago: string;
|
|
26
|
+
startingNow: string;
|
|
27
|
+
in: string;
|
|
28
|
+
day: string;
|
|
29
|
+
days: string;
|
|
30
|
+
watching: string;
|
|
31
|
+
notLive: string;
|
|
32
|
+
/** the card's label and the player's title when the stream has none */
|
|
33
|
+
video: string;
|
|
34
|
+
playHere: string;
|
|
35
|
+
play: string;
|
|
36
|
+
/** under the in-page player button; {provider} falls back to `platform` */
|
|
37
|
+
privacy: string;
|
|
38
|
+
platform: string;
|
|
39
|
+
/** site-wide signals */
|
|
40
|
+
bar: string;
|
|
41
|
+
watch: string;
|
|
42
|
+
dismissBar: string;
|
|
43
|
+
marker: string;
|
|
44
|
+
dismissDock: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const STRINGS: Record<string, LiveStrings> = {
|
|
48
|
+
en: {
|
|
49
|
+
tag: 'LIVE', liveNow: 'LIVE NOW', liveOn: 'LIVE ON {provider}', upcoming: 'UPCOMING', upcomingOn: 'UPCOMING ON {provider}',
|
|
50
|
+
watchOn: 'Watch on {provider} ↗', watchNow: 'Watch now ↗', openOn: 'Open on {provider} ↗', open: 'Open ↗', watchChannel: 'Watch our channel ↗',
|
|
51
|
+
scheduled: 'scheduled', started: 'started {time}', justNow: 'just now', ago: '{time} ago', startingNow: 'starting now', in: 'in {time}', day: '{n} day', days: '{n} days',
|
|
52
|
+
watching: '{count} watching', notLive: 'Not live right now', video: 'Live video', playHere: 'Play here', play: 'Play {title} here',
|
|
53
|
+
privacy: 'Playing loads video from {provider}.', platform: 'the platform',
|
|
54
|
+
bar: 'Live now', watch: 'Watch →', dismissBar: 'Dismiss live notice', marker: 'Live', dismissDock: 'Dismiss live player',
|
|
55
|
+
},
|
|
56
|
+
es: {
|
|
57
|
+
tag: 'EN VIVO', liveNow: 'EN VIVO AHORA', liveOn: 'EN VIVO EN {provider}', upcoming: 'PRÓXIMAMENTE', upcomingOn: 'PRÓXIMAMENTE EN {provider}',
|
|
58
|
+
watchOn: 'Ver en {provider} ↗', watchNow: 'Ver ahora ↗', openOn: 'Abrir en {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nuestro canal ↗',
|
|
59
|
+
scheduled: 'programado', started: 'empezó {time}', justNow: 'hace un momento', ago: 'hace {time}', startingNow: 'empieza ahora', in: 'en {time}', day: '{n} día', days: '{n} días',
|
|
60
|
+
watching: '{count} viendo', notLive: 'No estamos en vivo ahora', video: 'Video en vivo', playHere: 'Reproducir aquí', play: 'Reproducir {title} aquí',
|
|
61
|
+
privacy: 'Al reproducir, el video se carga desde {provider}.', platform: 'la plataforma',
|
|
62
|
+
bar: 'En vivo', watch: 'Ver →', dismissBar: 'Cerrar aviso', marker: 'En vivo', dismissDock: 'Cerrar reproductor',
|
|
63
|
+
},
|
|
64
|
+
fr: {
|
|
65
|
+
tag: 'EN DIRECT', liveNow: 'EN DIRECT', liveOn: 'EN DIRECT SUR {provider}', upcoming: 'À VENIR', upcomingOn: 'BIENTÔT SUR {provider}',
|
|
66
|
+
watchOn: 'Regarder sur {provider} ↗', watchNow: 'Regarder ↗', openOn: 'Ouvrir sur {provider} ↗', open: 'Ouvrir ↗', watchChannel: 'Voir notre chaîne ↗',
|
|
67
|
+
scheduled: 'programmé', started: 'a commencé {time}', justNow: 'à l’instant', ago: 'il y a {time}', startingNow: 'commence maintenant', in: 'dans {time}', day: '{n} jour', days: '{n} jours',
|
|
68
|
+
watching: '{count} spectateurs', notLive: 'Pas en direct pour le moment', video: 'Vidéo en direct', playHere: 'Lire ici', play: 'Lire {title} ici',
|
|
69
|
+
privacy: 'La lecture charge la vidéo depuis {provider}.', platform: 'la plateforme',
|
|
70
|
+
bar: 'En direct', watch: 'Regarder →', dismissBar: 'Fermer l’avis', marker: 'En direct', dismissDock: 'Fermer le lecteur',
|
|
71
|
+
},
|
|
72
|
+
de: {
|
|
73
|
+
tag: 'LIVE', liveNow: 'JETZT LIVE', liveOn: 'LIVE AUF {provider}', upcoming: 'DEMNÄCHST', upcomingOn: 'DEMNÄCHST AUF {provider}',
|
|
74
|
+
watchOn: 'Auf {provider} ansehen ↗', watchNow: 'Jetzt ansehen ↗', openOn: 'Auf {provider} öffnen ↗', open: 'Öffnen ↗', watchChannel: 'Zu unserem Kanal ↗',
|
|
75
|
+
scheduled: 'geplant', started: 'begann {time}', justNow: 'gerade eben', ago: 'vor {time}', startingNow: 'beginnt jetzt', in: 'in {time}', day: '{n} Tag', days: '{n} Tagen',
|
|
76
|
+
watching: '{count} Zuschauer', notLive: 'Gerade nicht live', video: 'Livevideo', playHere: 'Hier abspielen', play: '{title} hier abspielen',
|
|
77
|
+
privacy: 'Beim Abspielen wird das Video von {provider} geladen.', platform: 'der Plattform',
|
|
78
|
+
bar: 'Jetzt live', watch: 'Ansehen →', dismissBar: 'Hinweis schließen', marker: 'Live', dismissDock: 'Player schließen',
|
|
79
|
+
},
|
|
80
|
+
pt: {
|
|
81
|
+
tag: 'AO VIVO', liveNow: 'AO VIVO AGORA', liveOn: 'AO VIVO NO {provider}', upcoming: 'EM BREVE', upcomingOn: 'EM BREVE NO {provider}',
|
|
82
|
+
watchOn: 'Assistir no {provider} ↗', watchNow: 'Assistir agora ↗', openOn: 'Abrir no {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nosso canal ↗',
|
|
83
|
+
scheduled: 'agendado', started: 'começou {time}', justNow: 'agora mesmo', ago: 'há {time}', startingNow: 'começando agora', in: 'em {time}', day: '{n} dia', days: '{n} dias',
|
|
84
|
+
watching: '{count} assistindo', notLive: 'Não estamos ao vivo agora', video: 'Vídeo ao vivo', playHere: 'Reproduzir aqui', play: 'Reproduzir {title} aqui',
|
|
85
|
+
privacy: 'Ao reproduzir, o vídeo é carregado de {provider}.', platform: 'uma plataforma externa',
|
|
86
|
+
bar: 'Ao vivo', watch: 'Assistir →', dismissBar: 'Fechar aviso', marker: 'Ao vivo', dismissDock: 'Fechar player',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const liveStrings = (locale: string | null | undefined): LiveStrings =>
|
|
91
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
92
|
+
|
|
93
|
+
/** "Watch on {provider}" → "Watch on YouTube"; unknown slots stay as written */
|
|
94
|
+
export const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m);
|
package/src/lib/push/api.ts
CHANGED
|
@@ -101,10 +101,11 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
101
101
|
const now = Date.now();
|
|
102
102
|
const settings = await repo.getSettings();
|
|
103
103
|
const expo = expoOptions(locals);
|
|
104
|
-
const [stats, topics, calendars, links, log] = await Promise.all([
|
|
104
|
+
const [stats, topics, calendars, links, log, tags] = await Promise.all([
|
|
105
105
|
repo.push.stats(now), pushTopics(repo),
|
|
106
106
|
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
107
107
|
linkTargets(repo), repo.push.listNotifications(50),
|
|
108
|
+
CALENDAR.enabled ? repo.listTags() : Promise.resolve([]),
|
|
108
109
|
]);
|
|
109
110
|
return {
|
|
110
111
|
enabled: PUSH.enabled, retentionDays: PUSH.retentionDays,
|
|
@@ -115,6 +116,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
115
116
|
// "we're live" needs live video on (Integrations → Live video)
|
|
116
117
|
liveMode: settings.live.mode,
|
|
117
118
|
calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
119
|
+
// suggestions for the event-change rule's "Only events tagged"
|
|
120
|
+
tags: tags.map((t) => t.name),
|
|
118
121
|
expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
|
|
119
122
|
};
|
|
120
123
|
}
|
|
@@ -152,11 +155,20 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
|
|
|
152
155
|
const collections = new Set(Object.keys(CONTENT.collections));
|
|
153
156
|
const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
|
|
154
157
|
const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
|
|
158
|
+
const changes = input.auto.eventChanges;
|
|
159
|
+
const tags = changes?.tags ?? [];
|
|
160
|
+
const known = await knownTopics(repo);
|
|
155
161
|
const auto = {
|
|
156
162
|
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
157
163
|
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
158
164
|
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
159
165
|
live: { enabled: input.auto.live?.enabled === true },
|
|
166
|
+
eventChanges: CALENDAR.enabled ? {
|
|
167
|
+
calendars: cals(changes?.calendars ?? []),
|
|
168
|
+
// one of each tag, whatever its case (matching ignores case too)
|
|
169
|
+
tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
170
|
+
topics: [...new Set(changes?.topics ?? [])].filter((t) => known.has(t)),
|
|
171
|
+
} : { calendars: [], tags: [], topics: [] },
|
|
160
172
|
};
|
|
161
173
|
await repo.updateSettings({ push: { auto } });
|
|
162
174
|
return { auto };
|