@acms-subir/core 0.3.51 → 0.3.53
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 +33 -0
- package/cli/migrations.generated.mjs +16 -1
- package/docs/calendar.md +24 -10
- package/docs/mobile.md +1 -1
- package/integration.mjs +5 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/display/ScreensManager.tsx +481 -0
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/layouts/Admin.astro +2 -0
- package/src/lib/backup/content.ts +10 -1
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/display/board.ts +4 -3
- package/src/lib/display/screens.ts +111 -0
- package/src/lib/display/store.ts +113 -0
- package/src/lib/display/words.ts +16 -6
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +5 -0
- package/src/lib/repo/types.ts +5 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/screens/index.astro +24 -0
- package/src/pages/admin/calendar/screens/preview.astro +36 -0
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/api/calendar/screens.ts +30 -0
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/display/s/[key].astro +48 -0
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/styles/display.css +2 -1
- package/src/styles/forms.css +8 -0
- package/src/styles/screens-admin.css +53 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// A form in one of the site's other languages (schema.ts `translations`):
|
|
2
|
+
// the translated words laid over the main ones, word by word, so anything
|
|
3
|
+
// not translated yet stays in the main language. Pure (the builder uses it
|
|
4
|
+
// too); the caller picks the locale.
|
|
5
|
+
import type { FormDefinition, FormField } from './schema';
|
|
6
|
+
|
|
7
|
+
type Choices = Pick<FormField, 'options' | 'translations'>;
|
|
8
|
+
const pick = (v: string | undefined, fallback: string) => (v && v.trim() ? v : fallback);
|
|
9
|
+
|
|
10
|
+
/** A question's choices in one language, in the main order; untranslated ones stay as they are. */
|
|
11
|
+
export const choicesIn = (f: Choices, locale: string | null | undefined): string[] => {
|
|
12
|
+
const t = locale ? f.translations?.[locale]?.options : undefined;
|
|
13
|
+
return f.options.map((o, i) => t?.[i]?.trim() || o);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The main-language choice an answer stands for: itself, or the choice a translation of it sits in for. Undefined when it's neither. */
|
|
17
|
+
export function mainChoice(f: Choices, value: string): string | undefined {
|
|
18
|
+
if (f.options.includes(value)) return value;
|
|
19
|
+
for (const t of Object.values(f.translations ?? {})) {
|
|
20
|
+
const i = t.options?.findIndex(o => o.trim() === value) ?? -1;
|
|
21
|
+
if (i >= 0 && i < f.options.length) return f.options[i];
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A language in which a choice reads the same as a different choice (in any language), with that text; null when every text means one choice. */
|
|
27
|
+
export function choiceClash(f: Choices): { locale: string; text: string } | null {
|
|
28
|
+
const seen = new Map(f.options.map((o, i) => [o.trim(), i]));
|
|
29
|
+
for (const locale of Object.keys(f.translations ?? {})) {
|
|
30
|
+
for (const [i, text] of choicesIn(f, locale).entries()) {
|
|
31
|
+
const at = seen.get(text.trim());
|
|
32
|
+
if (at !== undefined && at !== i) return { locale, text };
|
|
33
|
+
seen.set(text.trim(), i);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The form as visitors in `locale` see it (main-language words where it has none), without any translations. */
|
|
40
|
+
export function localizeForm(d: FormDefinition, locale: string | null | undefined): FormDefinition {
|
|
41
|
+
const t = locale ? d.translations?.[locale] : undefined;
|
|
42
|
+
const { translations: _, ...rest } = d;
|
|
43
|
+
return {
|
|
44
|
+
...rest,
|
|
45
|
+
title: pick(t?.title, d.title), intro: pick(t?.intro, d.intro),
|
|
46
|
+
submitLabel: pick(t?.submitLabel, d.submitLabel), confirmation: pick(t?.confirmation, d.confirmation),
|
|
47
|
+
fields: d.fields.map((f) => {
|
|
48
|
+
const w = locale ? f.translations?.[locale] : undefined;
|
|
49
|
+
const { translations: _, ...field } = f;
|
|
50
|
+
const placeholder = pick(w?.placeholder, f.placeholder ?? '');
|
|
51
|
+
return { ...field, label: pick(w?.label, f.label), help: pick(w?.help, f.help), options: choicesIn(f, locale), ...(placeholder ? { placeholder } : {}) };
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a form's version is made of (service.ts formVersion): the questions,
|
|
58
|
+
* and the choices in each language (an answer names one), but none of the
|
|
59
|
+
* other words. A form without translated choices hashes as it always did.
|
|
60
|
+
*/
|
|
61
|
+
export function withoutWords(d: FormDefinition): FormDefinition {
|
|
62
|
+
const { translations: _, ...rest } = d;
|
|
63
|
+
return { ...rest, fields: d.fields.map(({ translations, ...f }) => {
|
|
64
|
+
const choices = Object.entries(translations ?? {}).filter(([, t]) => t.options?.some((o) => o.trim())).map(([l, t]) => [l, t.options!] as const);
|
|
65
|
+
return choices.length ? { ...f, choiceWords: Object.fromEntries(choices) } as FormField : f;
|
|
66
|
+
}) };
|
|
67
|
+
}
|
|
68
|
+
|
package/src/lib/forms/render.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { FormDefinition } from './schema';
|
|
2
2
|
import { formVersion } from './service';
|
|
3
3
|
import { escapeForm, renderFields } from './fields';
|
|
4
|
+
import { localizeForm } from './localize';
|
|
5
|
+
import { formStrings } from './words';
|
|
4
6
|
export { escapeForm, renderFields } from './fields';
|
|
5
7
|
export function hiddenFields(version: string, requestId: string) {
|
|
6
8
|
return `<input type="hidden" name="formVersion" value="${escapeForm(version)}"><input type="hidden" name="requestId" value="${escapeForm(requestId)}"><div class="pc-form-trap" aria-hidden="true"><label>Leave empty<input name="website" tabindex="-1" autocomplete="off"></label></div>`;
|
|
7
9
|
}
|
|
8
|
-
|
|
10
|
+
// locale: the form's language (its translations, '' = the main one); lang:
|
|
11
|
+
// the language of the page's own words; action: where it posts (/es/forms/<id>)
|
|
12
|
+
export async function renderForm(id: string, definition: FormDefinition, values: Record<string, string> = {}, error = '', requestId: string = crypto.randomUUID(), embed = false, o: { locale?: string; lang?: string; action?: string } = {}) {
|
|
9
13
|
const e = escapeForm;
|
|
10
|
-
|
|
14
|
+
const d = localizeForm(definition, o.locale), w = formStrings(o.lang);
|
|
15
|
+
const action = (o.action ?? `/forms/${encodeURIComponent(id)}`) + (embed ? '?embed=1' : '');
|
|
16
|
+
return `<section class="pc-simple-form">${d.title ? `<h2>${e(d.title)}</h2>` : ''}${d.intro ? `<p>${e(d.intro)}</p>` : ''}<form method="post" action="${e(action)}">${error ? `<p class="pc-bk-error" role="alert">${e(error)}</p>` : ''}${hiddenFields(await formVersion(definition), requestId)}${renderFields(d, values, `f-${id}`, w)}<button type="submit" class="pc-bk-submit">${e(d.submitLabel || w.submit)}</button></form></section>`;
|
|
11
17
|
}
|
package/src/lib/forms/schema.ts
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { choiceClash } from './localize';
|
|
2
3
|
|
|
3
4
|
// `checkbox` and `consent` both mean "must tick to agree" (consent is what the
|
|
4
5
|
// builder inserts; checkbox stays valid for existing data). `heading` has no
|
|
5
6
|
// answer: it is a section title (label) with an optional description (help).
|
|
6
|
-
|
|
7
|
+
// `time` is stored as 24-hour HH:mm, like `date` as YYYY-MM-DD.
|
|
8
|
+
export const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading', 'time'] as const;
|
|
7
9
|
export const CHOICE_TYPES: readonly string[] = ['select', 'radio', 'checkboxes'];
|
|
8
10
|
export const FIELD_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,79}$/;
|
|
11
|
+
// The site's other languages (lib/i18n.ts), by locale like DocTranslations:
|
|
12
|
+
// question: translations: { es: { label, help, placeholder, options } }
|
|
13
|
+
// form: translations: { es: { title, intro, submitLabel, confirmation } }
|
|
14
|
+
// Every word is optional; a blank or missing one shows the main language's
|
|
15
|
+
// (lib/forms/localize.ts). A translated choice is the choice at the same
|
|
16
|
+
// position, and answers always store the main-language one, so Responses,
|
|
17
|
+
// CSV and alerts read the same whatever language someone answered in.
|
|
18
|
+
const byLocale = <T extends z.ZodTypeAny>(words: T) =>
|
|
19
|
+
z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), words).refine(r => Object.keys(r).length <= 20, 'Too many languages.');
|
|
20
|
+
export const FieldWords = z.object({
|
|
21
|
+
label: z.string().trim().max(200).optional(), help: z.string().max(300).optional(),
|
|
22
|
+
placeholder: z.string().max(120).optional(),
|
|
23
|
+
// by position; '' = this choice isn't translated yet
|
|
24
|
+
options: z.array(z.string().trim().max(200).regex(/^[^\r\n]*$/, 'Each choice must be one line.')).max(50).optional(),
|
|
25
|
+
});
|
|
26
|
+
export type FieldWords = z.infer<typeof FieldWords>;
|
|
27
|
+
export const FormWords = z.object({
|
|
28
|
+
title: z.string().max(120).optional(), intro: z.string().max(1000).optional(),
|
|
29
|
+
submitLabel: z.string().max(80).optional(), confirmation: z.string().max(1000).optional(),
|
|
30
|
+
});
|
|
31
|
+
export type FormWords = z.infer<typeof FormWords>;
|
|
9
32
|
export const FormField = z.object({
|
|
10
33
|
id: z.string().regex(FIELD_ID),
|
|
11
34
|
type: z.enum(FIELD_TYPES), label: z.string().trim().min(1).max(200),
|
|
@@ -18,15 +41,21 @@ export const FormField = z.object({
|
|
|
18
41
|
placeholder: z.string().max(120).optional(),
|
|
19
42
|
width: z.enum(['full', 'half']).optional(),
|
|
20
43
|
allowOther: z.boolean().optional(),
|
|
44
|
+
translations: byLocale(FieldWords).optional(),
|
|
21
45
|
});
|
|
22
46
|
export type FormField = z.infer<typeof FormField>;
|
|
47
|
+
/** a new form's thank-you; untouched, it shows in the page's language (words.ts thanksText) */
|
|
48
|
+
export const DEFAULT_THANKS = 'Thank you. Your response has been received.';
|
|
23
49
|
export const FormDefinition = z.object({
|
|
24
50
|
title: z.string().max(120).default(''), intro: z.string().max(1000).default(''),
|
|
25
|
-
submitLabel: z.string().max(80).default(''), confirmation: z.string().max(1000).default(
|
|
51
|
+
submitLabel: z.string().max(80).default(''), confirmation: z.string().max(1000).default(DEFAULT_THANKS),
|
|
26
52
|
fields: z.array(FormField).max(30).default([]),
|
|
53
|
+
translations: byLocale(FormWords).optional(),
|
|
27
54
|
}).superRefine((d, ctx) => {
|
|
28
55
|
if (new Set(d.fields.map(f => f.id)).size !== d.fields.length) ctx.addIssue({ code: 'custom', message: 'Field IDs must be unique.' });
|
|
29
56
|
for (const f of d.fields) if (CHOICE_TYPES.includes(f.type) && (!f.options.length || new Set(f.options).size !== f.options.length)) ctx.addIssue({ code: 'custom', message: `“${f.label}” needs unique choices.` });
|
|
57
|
+
// one text, one choice: a translated answer must map back to a single choice
|
|
58
|
+
for (const f of d.fields) if (CHOICE_TYPES.includes(f.type) && choiceClash(f)) ctx.addIssue({ code: 'custom', message: `“${f.label}” has a translated choice that reads the same as another choice.` });
|
|
30
59
|
});
|
|
31
60
|
export type FormDefinition = z.infer<typeof FormDefinition>;
|
|
32
61
|
export const isHalf = (f: Pick<FormField, 'width' | 'type'>) => f.width === 'half' && f.type !== 'heading';
|
package/src/lib/forms/service.ts
CHANGED
|
@@ -2,8 +2,16 @@ import { ApiError } from '../api';
|
|
|
2
2
|
import { sha256Hex } from '../api-keys';
|
|
3
3
|
import { FormDefinition, answerFields, type FormResponse } from './schema';
|
|
4
4
|
import { AnswerError, checkAnswer } from './validate';
|
|
5
|
+
import { withoutWords } from './localize';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// The version is the questions and their choices in each language, not the
|
|
8
|
+
// other translated words: rewording a label, help or title doesn't change
|
|
9
|
+
// what's asked, so a page or app that loaded the form before still sends its
|
|
10
|
+
// answers (no 409). A choice is answered in any language and stored as the
|
|
11
|
+
// main-language one, so rewording a translated choice does change the
|
|
12
|
+
// version: an answer in the old words gets the 409 and a reload, never a
|
|
13
|
+
// wrong answer. Definitions without translated choices hash as before.
|
|
14
|
+
export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.stringify(withoutWords(FormDefinition.parse(definition))));
|
|
7
15
|
export async function collectResponse(definition: FormDefinition, input: Record<string, string>, version?: string): Promise<FormResponse> {
|
|
8
16
|
definition = FormDefinition.parse(definition);
|
|
9
17
|
const current = await formVersion(definition);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure answer reading + validation, shared by the server (collectResponse,
|
|
2
2
|
// readAnswers) and the builder's preview. No server imports here.
|
|
3
3
|
import { answerFields, hasOther, type FormDefinition, type FormField } from './schema';
|
|
4
|
+
import { mainChoice } from './localize';
|
|
4
5
|
|
|
5
6
|
/** Value of the "Other" radio/checkbox; its text arrives as `o_<field id>`. */
|
|
6
7
|
export const OTHER = '__other__';
|
|
@@ -28,12 +29,19 @@ export function answersFrom(getAll: (name: string) => unknown[], definition: Pic
|
|
|
28
29
|
return answers;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
+
/** HH:mm, 24-hour (an hour without its leading zero is fine: "9:05" is stored "09:05"). */
|
|
33
|
+
const TIME = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validates one answer and returns its stored form. Throws AnswerError with a
|
|
37
|
+
* visitor-facing message. A choice may come in any of the form's languages;
|
|
38
|
+
* it's stored as the main-language choice.
|
|
39
|
+
*/
|
|
32
40
|
export function checkAnswer(f: FormField, raw: unknown): string {
|
|
33
41
|
if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError(`“${f.label}” is too long.`);
|
|
34
42
|
if (f.type === 'heading') return '';
|
|
35
43
|
if (f.type === 'checkboxes') {
|
|
36
|
-
const items = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
44
|
+
const items = raw.split('\n').map(s => s.trim()).filter(Boolean).map(s => mainChoice(f, s) ?? s);
|
|
37
45
|
const extra = items.filter(i => !f.options.includes(i));
|
|
38
46
|
if (f.required && !items.length) throw new AnswerError(`Please answer “${f.label}”.`);
|
|
39
47
|
if (new Set(items).size !== items.length || extra.length > (hasOther(f) ? 1 : 0) || extra.some(e => e.length > 200)) throw invalid(f);
|
|
@@ -48,8 +56,12 @@ export function checkAnswer(f: FormField, raw: unknown): string {
|
|
|
48
56
|
if (f.type === 'email') valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
49
57
|
if (f.type === 'number') valid = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value) && Number.isFinite(Number(value));
|
|
50
58
|
if (f.type === 'date') valid = /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
51
|
-
if (f.type === '
|
|
52
|
-
if (f.type === '
|
|
59
|
+
if (f.type === 'time') { const m = TIME.exec(value); valid = Boolean(m); if (m) value = `${m[1].padStart(2, '0')}:${m[2]}`; }
|
|
60
|
+
if (f.type === 'select' || f.type === 'radio') {
|
|
61
|
+
const main = mainChoice(f, value);
|
|
62
|
+
if (main !== undefined) value = main;
|
|
63
|
+
else valid = f.type === 'radio' && hasOther(f) && value.length <= 200 && !value.includes('\n');
|
|
64
|
+
}
|
|
53
65
|
if (agree) valid = value === 'yes';
|
|
54
66
|
if (f.type === 'phone') valid = value.length <= 40 && /^[+\d().\s-]+$/.test(value);
|
|
55
67
|
if (f.type === 'url') {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The public form's own words, around the ones its author wrote: the button
|
|
2
|
+
// and the thank-you when the form has none, the dropdown's empty choice,
|
|
3
|
+
// "Other" with its text box, and "Send another response". In the page's
|
|
4
|
+
// language (/forms/<id> and /es/forms/<id>, an event's RSVP questions); the
|
|
5
|
+
// author's own words come from the form and its translations. {label}: the
|
|
6
|
+
// question (live/words.ts `fill`). Other languages fall back to English.
|
|
7
|
+
import { DEFAULT_THANKS } from './schema';
|
|
8
|
+
|
|
9
|
+
export interface FormStrings {
|
|
10
|
+
submit: string;
|
|
11
|
+
thanks: string;
|
|
12
|
+
choose: string;
|
|
13
|
+
other: string;
|
|
14
|
+
typeAnswer: string;
|
|
15
|
+
/** read out for the "Other" text box */
|
|
16
|
+
otherAnswer: string;
|
|
17
|
+
sendAnother: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STRINGS: Record<string, FormStrings> = {
|
|
21
|
+
en: { submit: 'Submit', thanks: 'Thank you. Your response has been received.', choose: 'Choose…', other: 'Other', typeAnswer: 'Type your answer', otherAnswer: 'Other answer for {label}', sendAnother: 'Send another response' },
|
|
22
|
+
es: { submit: 'Enviar', thanks: 'Gracias. Recibimos tu respuesta.', choose: 'Elige…', other: 'Otro', typeAnswer: 'Escribe tu respuesta', otherAnswer: 'Otra respuesta para {label}', sendAnother: 'Enviar otra respuesta' },
|
|
23
|
+
fr: { submit: 'Envoyer', thanks: 'Merci. Votre réponse a bien été reçue.', choose: 'Choisir…', other: 'Autre', typeAnswer: 'Saisissez votre réponse', otherAnswer: 'Autre réponse pour {label}', sendAnother: 'Envoyer une autre réponse' },
|
|
24
|
+
de: { submit: 'Senden', thanks: 'Danke. Ihre Antwort ist eingegangen.', choose: 'Auswählen…', other: 'Andere', typeAnswer: 'Ihre Antwort', otherAnswer: 'Andere Antwort für {label}', sendAnother: 'Weitere Antwort senden' },
|
|
25
|
+
pt: { submit: 'Enviar', thanks: 'Obrigado. Recebemos sua resposta.', choose: 'Escolha…', other: 'Outro', typeAnswer: 'Digite sua resposta', otherAnswer: 'Outra resposta para {label}', sendAnother: 'Enviar outra resposta' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const formStrings = (locale: string | null | undefined): FormStrings =>
|
|
29
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
30
|
+
|
|
31
|
+
/** The form's thank-you, or the page's own words when it has none (or still has the default one). */
|
|
32
|
+
export const thanksText = (confirmation: string, w: FormStrings) => (confirmation.trim() && confirmation !== DEFAULT_THANKS ? confirmation : w.thanks);
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -3,17 +3,24 @@ import type { ContentRepository } from '../repo/types';
|
|
|
3
3
|
import type { FormDefinition, SavedForm } from '../forms/schema';
|
|
4
4
|
import { collectResponse, formVersion } from '../forms/service';
|
|
5
5
|
import { saveResponse } from '../forms/alerts';
|
|
6
|
+
import { localizeForm } from '../forms/localize';
|
|
7
|
+
import { formStrings, thanksText } from '../forms/words';
|
|
6
8
|
import { ApiError, readJson } from '../api';
|
|
7
9
|
import { sha256Hex } from '../api-keys';
|
|
10
|
+
import { I18N } from '../i18n';
|
|
11
|
+
import { isExtraLocale } from '../translations';
|
|
12
|
+
import { siteLang } from '../dates';
|
|
8
13
|
import { queryParams } from './http';
|
|
9
14
|
import { requireAccess } from './access';
|
|
15
|
+
import { localeOnlyQuery, mobileLocale } from './locale';
|
|
10
16
|
import type { PublicContext } from './route';
|
|
11
17
|
|
|
12
|
-
// Published general forms for apps: read the questions
|
|
13
|
-
//
|
|
14
|
-
// limits: an Idempotency-Key per
|
|
15
|
-
// responses per address per form per
|
|
16
|
-
// from Admin → Mobile app so one form
|
|
18
|
+
// Published general forms for apps: read the questions (in the app's
|
|
19
|
+
// language with ?locale=), send answers. The same validation and storage as
|
|
20
|
+
// the web form (/forms/<id>), plus app limits: an Idempotency-Key per
|
|
21
|
+
// response (retries never duplicate), 10 responses per address per form per
|
|
22
|
+
// minute, and a per-form daily ceiling from Admin → Mobile app so one form
|
|
23
|
+
// can't be flooded.
|
|
17
24
|
|
|
18
25
|
const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
19
26
|
|
|
@@ -25,10 +32,15 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
|
|
|
25
32
|
return form;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A form's questions for apps, in `locale` (the main-language words where it
|
|
37
|
+
* has none; never the translations themselves); `version` is the one
|
|
38
|
+
* collectResponse accepts (also for event RSVPs), the same in every language.
|
|
39
|
+
*/
|
|
40
|
+
export async function formJson(definition: FormDefinition, locale?: string | null) {
|
|
41
|
+
const d = localizeForm(definition, isExtraLocale(locale) ? locale : '');
|
|
30
42
|
return {
|
|
31
|
-
version: await formVersion(
|
|
43
|
+
version: await formVersion(definition),
|
|
32
44
|
title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
|
|
33
45
|
fields: d.fields.map((f) => ({
|
|
34
46
|
id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
|
|
@@ -37,12 +49,26 @@ export async function formJson(d: FormDefinition) {
|
|
|
37
49
|
};
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/** the language a request asks for (`?locale=`), or the site's main one */
|
|
53
|
+
function formLocale(url: URL, read: boolean) {
|
|
54
|
+
if (read) return mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
55
|
+
// sending answers never fails over the language: one the site doesn't
|
|
56
|
+
// have gets the main language's thank-you (the response is the same)
|
|
57
|
+
const locale = url.searchParams.get('locale');
|
|
58
|
+
return locale && I18N.locales.includes(locale) ? locale : I18N.defaultLocale || null;
|
|
59
|
+
}
|
|
60
|
+
// the button and thank-you when the form has none, in the request's language (else the site's)
|
|
61
|
+
async function ownWords(repo: ContentRepository, locale: string | null) {
|
|
62
|
+
return formStrings(locale ?? siteLang(await repo.getSettings()));
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
41
|
-
|
|
66
|
+
const locale = formLocale(url, true);
|
|
42
67
|
await requireAccess(repo, 'forms');
|
|
43
68
|
const form = await publishedForm(repo, id);
|
|
44
|
-
const
|
|
45
|
-
|
|
69
|
+
const json = await formJson(form.definition, locale);
|
|
70
|
+
const w = await ownWords(repo, locale);
|
|
71
|
+
return { id: form.id, ...json, title: json.title || form.name, submitLabel: json.submitLabel || w.submit, confirmation: thanksText(json.confirmation, w) };
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
// field id → answer; checkbox groups send a list. "Other" answers send the typed text.
|
|
@@ -60,6 +86,7 @@ export const formResponseInput = z.object({
|
|
|
60
86
|
}).strict();
|
|
61
87
|
|
|
62
88
|
export async function submitFormResponse(c: PublicContext) {
|
|
89
|
+
const locale = formLocale(c.url, false);
|
|
63
90
|
const settings = await requireAccess(c.repo, 'forms');
|
|
64
91
|
const id = c.params.id ?? '';
|
|
65
92
|
const key = c.request.headers.get('idempotency-key') ?? '';
|
|
@@ -77,12 +104,17 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
77
104
|
const unchanged = JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition);
|
|
78
105
|
if (unchanged && JSON.stringify(await collectResponse(form.definition, answers, body.version)) !== prior)
|
|
79
106
|
throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
|
|
80
|
-
return
|
|
107
|
+
return receipt(c.repo, form, locale);
|
|
81
108
|
}
|
|
82
109
|
const response = await collectResponse(form.definition, answers, body.version);
|
|
83
110
|
// only valid new responses count toward the daily ceiling
|
|
84
111
|
const cap = settings.mobileAccess.responsesPerFormDay;
|
|
85
112
|
if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw new ApiError(429, 'This form has had a lot of responses today. Please try again tomorrow.', 86_400);
|
|
86
113
|
await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
|
|
87
|
-
return
|
|
114
|
+
return receipt(c.repo, form, locale);
|
|
115
|
+
}
|
|
116
|
+
// the thank-you in the app's language (?locale=), as the web form shows it
|
|
117
|
+
async function receipt(repo: ContentRepository, form: SavedForm, locale: string | null) {
|
|
118
|
+
const d = localizeForm(form.definition, isExtraLocale(locale) ? locale : '');
|
|
119
|
+
return { received: true as const, confirmation: thanksText(d.confirmation, await ownWords(repo, locale)) };
|
|
88
120
|
}
|
|
@@ -61,7 +61,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
61
61
|
['/announcements/{id}', 'One announcement that is up, in the requested language; requires announcements', localeOnlyQuery, ref('AnnouncementDetail')],
|
|
62
62
|
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
63
63
|
['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
|
|
64
|
-
['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)',
|
|
64
|
+
['/forms/{id}', 'A published form’s questions, in the requested language (locale: its labels, help, choices, button and thank-you there; words it has no translation for stay in the main language; version is the same in every language); 410 when it stopped accepting responses (owner switch: forms)', localeOnlyQuery, ref('Form')],
|
|
65
65
|
['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
|
|
66
66
|
];
|
|
67
67
|
for (const [path, summary, query, schema, cache = true] of reads) {
|
|
@@ -114,8 +114,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
114
114
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
115
115
|
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
116
116
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
117
|
-
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409. Leave website empty (spam trap).' }),
|
|
118
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
117
|
+
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation in that language (one the site doesn’t have gets the main language’s). Leave website empty (spam trap).' }),
|
|
118
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'locale', in: 'query', required: false, schema: { type: 'string', minLength: 1, maxLength: 35 } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
119
119
|
const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
|
|
120
120
|
paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
|
|
121
121
|
description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -61,7 +61,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
61
61
|
const closed = rsvpClosed(o, settings);
|
|
62
62
|
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
63
63
|
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
64
|
-
const
|
|
64
|
+
const questions = rsvpQuestions(o.event);
|
|
65
|
+
// the questions in the app's language, where the form has its words
|
|
66
|
+
const form = questions ? await formJson(questions, locale) : null;
|
|
65
67
|
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
66
68
|
// the built-in button speaks the app's language (the site's own without languages set up)
|
|
67
69
|
let submitLabel = form?.submitLabel ?? '';
|
|
@@ -79,6 +81,6 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
79
81
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
80
82
|
// a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
|
|
81
83
|
waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
|
|
82
|
-
form: form ? { ...
|
|
84
|
+
form: form ? { ...form, submitLabel } : null,
|
|
83
85
|
};
|
|
84
86
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale } from '../schema';
|
|
3
|
+
import { FIELD_TYPES } from '../forms/schema';
|
|
3
4
|
|
|
4
5
|
const str = z.string(), bool = z.boolean(), strings = z.array(str);
|
|
5
6
|
const date = str.datetime(), locale = str.nullable();
|
|
@@ -73,7 +74,8 @@ export const Links = z.object({
|
|
|
73
74
|
export const Form = z.object({
|
|
74
75
|
id: str, version: str, title: str, intro: str, submitLabel: str, confirmation: str,
|
|
75
76
|
fields: z.array(z.object({
|
|
76
|
-
|
|
77
|
+
// time: 24-hour HH:mm. An app that doesn't know a type can ask it as short text; the site checks the answer
|
|
78
|
+
id: str, type: z.enum(FIELD_TYPES),
|
|
77
79
|
label: str, required: bool, help: str, options: strings, placeholder: str, width: z.enum(['full', 'half']), allowOther: bool,
|
|
78
80
|
})),
|
|
79
81
|
});
|
|
@@ -10,6 +10,7 @@ import { PLACES_MIGRATION } from '../maps/migration';
|
|
|
10
10
|
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
11
11
|
import { SYNC_MIGRATION } from '../sync/migration';
|
|
12
12
|
import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
|
|
13
|
+
import { SCREENS_MIGRATION } from '../display/store';
|
|
13
14
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
14
15
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
15
16
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -727,6 +728,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
727
728
|
// single dates of a series moved (CalEvent.movedDates): a column on the
|
|
728
729
|
// row, so the triggers on events (content version, event_changes) see a move
|
|
729
730
|
MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
|
|
731
|
+
// saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
|
|
732
|
+
SCREENS_MIGRATION,
|
|
730
733
|
];
|
|
731
734
|
|
|
732
735
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { GuestStore } from '../mobile/guest-store';
|
|
|
6
6
|
import { LiveStore } from '../live/store';
|
|
7
7
|
import { PushStore } from '../push/store';
|
|
8
8
|
import { AnnouncementStore, announcementInsert } from '../announcements/store';
|
|
9
|
+
import { ScreenStore, screenInsert, screenKey } from '../display/store';
|
|
9
10
|
import { EditorStore } from '../editor/store';
|
|
10
11
|
import { PermissionStore } from '../permissions/store';
|
|
11
12
|
import { BackupStore } from '../backup/store';
|
|
@@ -296,6 +297,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
296
297
|
readonly editor: EditorStore;
|
|
297
298
|
readonly backups: BackupStore;
|
|
298
299
|
readonly permissions: PermissionStore;
|
|
300
|
+
readonly screens: ScreenStore;
|
|
299
301
|
|
|
300
302
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
301
303
|
this.ready = this.init(driver);
|
|
@@ -310,6 +312,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
310
312
|
this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
|
|
311
313
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
312
314
|
this.permissions = new PermissionStore(async () => { await this.ready; return this.driver; });
|
|
315
|
+
this.screens = new ScreenStore(async () => { await this.ready; return this.driver; });
|
|
313
316
|
}
|
|
314
317
|
|
|
315
318
|
private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
@@ -1938,6 +1941,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1938
1941
|
// announcements (a backup made before them leaves the current ones). Restored
|
|
1939
1942
|
// rows never push again here: the push ledger keeps announcement:<id>
|
|
1940
1943
|
...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
|
|
1944
|
+
// screens come back with new keys (backups never hold them): each TV needs its new link
|
|
1945
|
+
...(content.screens ? [{ sql: 'DELETE FROM display_screens' }, ...content.screens.map((s) => screenInsert({ ...s, key: screenKey(), lastSeenAt: null }))] : []),
|
|
1941
1946
|
...(content.forms ?? []).map(formInsert),
|
|
1942
1947
|
...(content.formSubmissions ?? []).map(submissionInsert),
|
|
1943
1948
|
...(content.resources ?? []).map((r) => this.resourceInsert(r)),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { AnnouncementStore } from '../announcements/store';
|
|
|
13
13
|
import type { Announcement } from '../announcements/types';
|
|
14
14
|
import type { EditorStore } from '../editor/store';
|
|
15
15
|
import type { PermissionStore } from '../permissions/store';
|
|
16
|
+
import type { Screen, ScreenStore } from '../display/store';
|
|
16
17
|
export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
|
|
17
18
|
export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
|
|
18
19
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
@@ -83,6 +84,7 @@ export interface ContentRepository {
|
|
|
83
84
|
readonly announcements: AnnouncementStore;
|
|
84
85
|
readonly editor: EditorStore;
|
|
85
86
|
readonly permissions: PermissionStore;
|
|
87
|
+
readonly screens: ScreenStore;
|
|
86
88
|
// Bounded public catalog; fetch limit + 1 IDs without loading every body.
|
|
87
89
|
listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
|
|
88
90
|
exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
|
|
@@ -367,6 +369,9 @@ export interface ContentRepository {
|
|
|
367
369
|
formSubmissions?: FormSubmission[];
|
|
368
370
|
// added with announcements; a backup without them leaves the current ones
|
|
369
371
|
announcements?: Announcement[];
|
|
372
|
+
// saved TV screens, without their keys (each gets a new one); a backup
|
|
373
|
+
// without them leaves the current ones
|
|
374
|
+
screens?: Screen[];
|
|
370
375
|
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
371
376
|
|
|
372
377
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
|
@@ -22,7 +22,7 @@ const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astr
|
|
|
22
22
|
if (!page || (page.status !== 'published' && !isAdmin)) {
|
|
23
23
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
24
24
|
}
|
|
25
|
-
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
|
25
|
+
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo, locale)) : '';
|
|
26
26
|
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
27
27
|
const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
28
28
|
const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import ScreensManager from '../../../../components/display/ScreensManager';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { CALENDAR } from '../../../../lib/calendar/config';
|
|
6
|
+
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
7
|
+
|
|
8
|
+
// Admin → Calendar → Screens: saved TV screens and quick links
|
|
9
|
+
// (lib/display). A screen's link can show private calendars, so it takes the
|
|
10
|
+
// same permission as calendars and rooms.
|
|
11
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
12
|
+
const repo = await getRepo(Astro.locals);
|
|
13
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
|
|
14
|
+
if (denied) return denied;
|
|
15
|
+
---
|
|
16
|
+
<Admin title="Screens" section="calendar:screens" wide>
|
|
17
|
+
<div class="page-head">
|
|
18
|
+
<div>
|
|
19
|
+
<h1>Screens</h1>
|
|
20
|
+
<p class="sub">Full-screen calendars for TVs. A change here reaches the TV within 5 minutes.</p>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<ScreensManager client:only="react" />
|
|
24
|
+
</Admin>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import DisplayPage from '../../../../components/display/DisplayPage.astro';
|
|
3
|
+
import { getRepo } from '../../../../lib/repo';
|
|
4
|
+
import { CALENDAR } from '../../../../lib/calendar/config';
|
|
5
|
+
import { I18N, localePath } from '../../../../lib/i18n';
|
|
6
|
+
import { siteOrigin } from '../../../../lib/seo';
|
|
7
|
+
import { siteLang } from '../../../../lib/dates';
|
|
8
|
+
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
9
|
+
import { readDisplaySettings } from '../../../../lib/display/settings';
|
|
10
|
+
import { loadDisplayBoard } from '../../../../lib/display/board';
|
|
11
|
+
|
|
12
|
+
// The Screens editor's live preview (a 16:9 frame beside the settings): the
|
|
13
|
+
// board as the TV will show it, from settings not saved yet. ?lang= picks the
|
|
14
|
+
// language, ?private=1 shows what a screen with private details shows (for
|
|
15
|
+
// people who may see bookings). It never refreshes, keeps no offline copy
|
|
16
|
+
// and counts as no screen's check-in.
|
|
17
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
18
|
+
const repo = await getRepo(Astro.locals);
|
|
19
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
|
|
20
|
+
if (denied) return denied;
|
|
21
|
+
const site = await repo.getSettings();
|
|
22
|
+
const q = Astro.url.searchParams;
|
|
23
|
+
const canPrivate = Boolean((await accessFor(Astro.request, Astro.cookies, repo))?.can('schedule.bookings'));
|
|
24
|
+
const settings = readDisplaySettings(q);
|
|
25
|
+
const wanted = q.get('lang') ?? '';
|
|
26
|
+
const lang = I18N.enabled && I18N.locales.includes(wanted) ? wanted : siteLang(site);
|
|
27
|
+
const board = await loadDisplayBoard(repo, site, {
|
|
28
|
+
settings, privateDetails: q.get('private') === '1' && canPrivate, lang,
|
|
29
|
+
phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
|
|
30
|
+
});
|
|
31
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
32
|
+
Astro.response.headers.set('Content-Security-Policy', "frame-ancestors 'self'");
|
|
33
|
+
Astro.response.headers.set('Referrer-Policy', 'no-referrer');
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<DisplayPage site={site} board={board} settings={settings} title="Screen preview" lang={lang} live={false} />
|
|
@@ -3,14 +3,18 @@ import Admin from '../../../layouts/Admin.astro';
|
|
|
3
3
|
import FormHeader from '../../../components/forms/FormHeader.astro';
|
|
4
4
|
import FormBuildPage from '../../../components/forms/FormBuildPage';
|
|
5
5
|
import { getRepo } from '../../../lib/repo';
|
|
6
|
+
import { I18N, extraLocales, localeName } from '../../../lib/i18n';
|
|
6
7
|
import '../../../styles/forms.css';
|
|
7
8
|
// Build tab. Edits autosave as a draft through the `forms.save` action;
|
|
8
9
|
// "Publish changes" makes them live. See components/forms/FormBuildPage.tsx.
|
|
10
|
+
// With more than one site language, a switcher writes the form's words in each.
|
|
9
11
|
const repo = await getRepo(Astro.locals);
|
|
10
12
|
const form = await repo.forms.get(Astro.params.id || '');
|
|
11
13
|
if (!form) return new Response(null, { status: 404 });
|
|
12
14
|
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
13
15
|
const responses = form.kind === 'form' && form.purpose === 'general' ? (await repo.forms.submissions(form.id, 1)).length : 0;
|
|
16
|
+
const languages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
|
|
17
|
+
const mainLanguage = { code: I18N.defaultLocale || 'en', name: localeName(I18N.defaultLocale || 'en', true) };
|
|
14
18
|
---
|
|
15
19
|
<Admin title={form.name} section="forms" wide>
|
|
16
20
|
<FormHeader form={form} active="build">
|
|
@@ -23,5 +27,7 @@ const responses = form.kind === 'form' && form.purpose === 'general' ? (await re
|
|
|
23
27
|
form={{ id: form.id, name: form.name, kind: form.kind, purpose: form.purpose, status: form.status, revision: form.revision, hasDraft: Boolean(form.draft) }}
|
|
24
28
|
definition={form.draft ?? form.definition}
|
|
25
29
|
responses={responses}
|
|
30
|
+
languages={languages}
|
|
31
|
+
mainLanguage={mainLanguage}
|
|
26
32
|
/>
|
|
27
33
|
</Admin>
|