caracms 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage under the inbox (M6 Decision 9; the mockup's #submission, #submission-failed,
|
|
3
|
+
* #submission-skipped, #submission-unknown): the last page stays, dimmed (shell/Stage.tsx),
|
|
4
|
+
* and a submission opens over it on a light sheet, with the list kept in the sidebar.
|
|
5
|
+
*
|
|
6
|
+
* The sheet: the form and the full time in the site's zone, who sent it and their address,
|
|
7
|
+
* **Reply by email** (a `mailto:` with the address percent-encoded and the subject
|
|
8
|
+
* "Re: your enquiry to <brand>"), **Mark unread** (which closes it: staying open would be
|
|
9
|
+
* reading it again), **Delete…** (after a confirm: documents/dialogs.tsx); then the email's
|
|
10
|
+
* outcome (sent to whom, failed with Resend's message, skipped and why, or unknown), and
|
|
11
|
+
* every field by its label, files as downloads with their sizes.
|
|
12
|
+
*
|
|
13
|
+
* **Visitor strings are only ever text** (React's escaping): no `dangerouslySetInnerHTML`
|
|
14
|
+
* on this path. Files always download (`download`, and the route's `Content-Disposition:
|
|
15
|
+
* attachment`).
|
|
16
|
+
*/
|
|
17
|
+
import { Fragment, useEffect, useRef } from 'react';
|
|
18
|
+
import { senderName, shownFileName } from '../../../forms/rules.ts';
|
|
19
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
20
|
+
import { openDialog } from '../documents/dialogs.tsx';
|
|
21
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
22
|
+
import type { Editor } from '../editor.ts';
|
|
23
|
+
import type { EmailOutcome, StoredSubmission, SubmissionResponse } from '../../types.ts';
|
|
24
|
+
import { ENDPOINTS } from '../../types.ts';
|
|
25
|
+
import { fileLine, fullTime, inboxFor, replyHref, shortTime } from './store.ts';
|
|
26
|
+
|
|
27
|
+
export function InboxStage({ back, onBack }: { back: string; onBack: () => void }) {
|
|
28
|
+
const editor = useEditor();
|
|
29
|
+
const inbox = inboxFor(editor);
|
|
30
|
+
const openId = useStore(editor.view, (v) => v.inbox?.id ?? null);
|
|
31
|
+
const open = useStore(inbox.open);
|
|
32
|
+
const off = useStore(editor.submissions, (s) => s.unread === null);
|
|
33
|
+
const empty = useStore(inbox.list, (l) => l.rows !== null && l.rows.length === 0);
|
|
34
|
+
if (openId && open?.id === openId && open.answer) return <Sheet answer={open.answer} />;
|
|
35
|
+
if (openId && open?.id === openId && open.error) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="in">
|
|
38
|
+
<Icon name="inbox" />
|
|
39
|
+
<h2>This message can’t be shown</h2>
|
|
40
|
+
<p>{open.error}</p>
|
|
41
|
+
<button type="button" className="btn" onClick={() => editor.openInbox(null)}>
|
|
42
|
+
<Icon name="chev-l" size="sm" />
|
|
43
|
+
Back to the list
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
// Opening one: the last state stays until it's there (no skeleton), so nothing flickers.
|
|
49
|
+
const h = off ? 'Nothing to show yet' : empty ? 'Nothing to read yet' : 'Choose a message to read it';
|
|
50
|
+
return (
|
|
51
|
+
<div className="in">
|
|
52
|
+
<Icon name="inbox" />
|
|
53
|
+
<h2>{h}</h2>
|
|
54
|
+
<button type="button" className="btn" data-act="back-to-page" onClick={onBack}>
|
|
55
|
+
<Icon name="chev-l" size="sm" />
|
|
56
|
+
Back to {back}
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The form's fields as the sheet lists them: the config's, in order; a form no longer in the config, the stored names. */
|
|
63
|
+
function fieldRows(answer: SubmissionResponse) {
|
|
64
|
+
const { submission, form } = answer;
|
|
65
|
+
const values = submission.fields ?? {};
|
|
66
|
+
if (form) return form.fields.map((f) => ({ name: f.name, label: f.label, type: f.type, options: f.options }));
|
|
67
|
+
const rows: { name: string; label: string; type: string; options?: { value: string; label: string }[] }[] = Object.keys(values).map((name) => ({ name, label: name, type: 'text' }));
|
|
68
|
+
if (submission.files?.length) rows.push({ name: 'files', label: 'Files', type: 'files' });
|
|
69
|
+
return rows;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Sheet({ answer }: { answer: SubmissionResponse }) {
|
|
73
|
+
const editor = useEditor();
|
|
74
|
+
const inbox = inboxFor(editor);
|
|
75
|
+
const brand = useStore(editor.brand, (b) => b.name);
|
|
76
|
+
const { submission, form } = answer;
|
|
77
|
+
const timeZone = editor.timeZone();
|
|
78
|
+
const values = submission.fields ?? {};
|
|
79
|
+
const who = senderName(form, values) || 'Someone';
|
|
80
|
+
const address = typeof values.email === 'string' ? values.email : '';
|
|
81
|
+
const formLabel = form?.label ?? submission.form;
|
|
82
|
+
const subject = `Re: your enquiry to ${brand}`;
|
|
83
|
+
const body = useRef<HTMLDivElement>(null);
|
|
84
|
+
// Braces: Chromium's scrollTo returns a promise now, and an effect may only return a cleanup.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
body.current?.scrollTo(0, 0);
|
|
87
|
+
}, [submission.id]);
|
|
88
|
+
|
|
89
|
+
const markUnread = async () => {
|
|
90
|
+
const answer = await inbox.markUnread(submission.id);
|
|
91
|
+
if (!answer.ok) {
|
|
92
|
+
editor.toast(answer.error ?? "It couldn't be marked unread.", 'warn');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Only if it's still the one open (another may have been opened meanwhile).
|
|
96
|
+
if (editor.view.get().inbox?.id === submission.id) editor.openInbox(null);
|
|
97
|
+
editor.toast('Marked unread', 'unread');
|
|
98
|
+
requestAnimationFrame(() => document.querySelector<HTMLElement>(`#side .srow[data-id="${CSS.escape(submission.id)}"]`)?.focus());
|
|
99
|
+
};
|
|
100
|
+
const close = () => {
|
|
101
|
+
editor.openInbox(null);
|
|
102
|
+
requestAnimationFrame(() => document.querySelector<HTMLElement>(`#side .srow[data-id="${CSS.escape(submission.id)}"]`)?.focus());
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<article className="sub-sheet" aria-labelledby="sub-h" data-submission={submission.id}>
|
|
107
|
+
<header className="sub-head">
|
|
108
|
+
<div className="sub-meta">
|
|
109
|
+
<span className="chip">
|
|
110
|
+
<Icon name="b-form" size="xs" />
|
|
111
|
+
{formLabel} form
|
|
112
|
+
</span>
|
|
113
|
+
<span>{fullTime(submission.receivedAt, timeZone)}</span>
|
|
114
|
+
<button type="button" className="ib end" style={{ marginLeft: 'auto' }} data-act="sub-close" aria-label="Close" onClick={close}>
|
|
115
|
+
<Icon name="x" size="sm" />
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
<h2 id="sub-h" tabIndex={-1}>
|
|
119
|
+
{who}
|
|
120
|
+
</h2>
|
|
121
|
+
<p className="from">{address}</p>
|
|
122
|
+
<div className="sub-acts">
|
|
123
|
+
{address ? (
|
|
124
|
+
<a className="btn ink" href={replyHref(address, brand)} data-act="reply" title={`Opens your email, with the subject “${subject}”`}>
|
|
125
|
+
<Icon name="reply" size="sm" />
|
|
126
|
+
Reply by email
|
|
127
|
+
</a>
|
|
128
|
+
) : null}
|
|
129
|
+
<button type="button" className="btn" data-act="sub-unread" onClick={() => void markUnread()}>
|
|
130
|
+
<Icon name="unread" size="sm" />
|
|
131
|
+
Mark unread
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="btn quiet-danger end"
|
|
136
|
+
data-act="sub-delete"
|
|
137
|
+
onClick={() => openDialog(editor, { kind: 'sub-delete', id: submission.id, who, files: submission.files?.length ?? 0 })}
|
|
138
|
+
>
|
|
139
|
+
<Icon name="trash" size="sm" />
|
|
140
|
+
Delete…
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</header>
|
|
144
|
+
<div className="sub-body" ref={body}>
|
|
145
|
+
<MailOutcome email={submission.email} formLabel={formLabel} timeZone={timeZone} editor={editor} />
|
|
146
|
+
<dl className="sub-fields">
|
|
147
|
+
{fieldRows(answer).map((f) => {
|
|
148
|
+
if (f.type === 'files') {
|
|
149
|
+
const files = submission.files ?? [];
|
|
150
|
+
return [
|
|
151
|
+
<dt key={`${f.name}:t`}>{f.label}</dt>,
|
|
152
|
+
files.length ? (
|
|
153
|
+
<dd key={`${f.name}:d`}>
|
|
154
|
+
<div className="sub-files">
|
|
155
|
+
{files.map((file, i) => (
|
|
156
|
+
<FileRow key={file.key} submission={submission} n={i + 1} name={shownFileName(file.name) || shownFileName(file.key)} size={file.size} keyName={file.key} />
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
</dd>
|
|
160
|
+
) : (
|
|
161
|
+
<dd key={`${f.name}:d`} className="none">
|
|
162
|
+
No files
|
|
163
|
+
</dd>
|
|
164
|
+
),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
const raw = typeof values[f.name] === 'string' ? values[f.name]! : '';
|
|
168
|
+
const shown = f.type === 'select' ? (f.options?.find((o) => o.value === raw)?.label ?? raw) : raw;
|
|
169
|
+
return [
|
|
170
|
+
<dt key={`${f.name}:t`}>{f.label}</dt>,
|
|
171
|
+
shown ? (
|
|
172
|
+
<dd key={`${f.name}:d`} data-value={f.name}>
|
|
173
|
+
{shown}
|
|
174
|
+
</dd>
|
|
175
|
+
) : (
|
|
176
|
+
<dd key={`${f.name}:d`} className="none">
|
|
177
|
+
Not given
|
|
178
|
+
</dd>
|
|
179
|
+
),
|
|
180
|
+
];
|
|
181
|
+
})}
|
|
182
|
+
</dl>
|
|
183
|
+
</div>
|
|
184
|
+
</article>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function FileRow({ submission, n, name, size, keyName }: { submission: StoredSubmission; n: number; name: string; size: number; keyName: string }) {
|
|
189
|
+
// The stored key's extension (what the download's type comes from), as the mockup shows it.
|
|
190
|
+
const ext = (/\.([a-z0-9]+)$/i.exec(keyName)?.[1] ?? '').toUpperCase().replace('JPEG', 'JPG');
|
|
191
|
+
return (
|
|
192
|
+
<a className="frow" href={`${ENDPOINTS.submissions}/${encodeURIComponent(submission.id)}/files/${n}`} download={name} title={`Download ${name}`} data-file={n}>
|
|
193
|
+
<span className="ext">{ext}</span>
|
|
194
|
+
<span className="t">
|
|
195
|
+
<b>{name}</b>
|
|
196
|
+
<small>{fileLine(size, ext)}</small>
|
|
197
|
+
</span>
|
|
198
|
+
<Icon name="download" />
|
|
199
|
+
</a>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function MailOutcome({ email, formLabel, timeZone, editor }: { email: EmailOutcome | undefined; formLabel: string; timeZone: string; editor: Editor }) {
|
|
204
|
+
if (!email) {
|
|
205
|
+
return (
|
|
206
|
+
<div className="note warn" data-mail="unknown">
|
|
207
|
+
<Icon name="warn" />
|
|
208
|
+
<div>
|
|
209
|
+
<b>It isn’t known whether the email went out</b>
|
|
210
|
+
<p>Check with whoever gets {formLabel} emails.</p>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (email.status === 'sent') {
|
|
216
|
+
return (
|
|
217
|
+
<p className="sub-mail sent" data-mail="sent">
|
|
218
|
+
<Icon name="check" />
|
|
219
|
+
<span>
|
|
220
|
+
Emailed to{' '}
|
|
221
|
+
{email.to.map((a, i) => (
|
|
222
|
+
<Fragment key={a}>
|
|
223
|
+
{i === 0 ? '' : i === email.to.length - 1 ? ' and ' : ', '}
|
|
224
|
+
<span className="mono">{a}</span>
|
|
225
|
+
</Fragment>
|
|
226
|
+
))}{' '}
|
|
227
|
+
at {shortTime(email.at, timeZone)}.
|
|
228
|
+
</span>
|
|
229
|
+
</p>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (email.status === 'failed') {
|
|
233
|
+
return (
|
|
234
|
+
<div className="note danger" data-mail="failed">
|
|
235
|
+
<Icon name="warn" />
|
|
236
|
+
<div>
|
|
237
|
+
<b>The email about it didn’t go out</b>
|
|
238
|
+
<p>Resend said:</p>
|
|
239
|
+
<q>{email.error ?? 'No reason was given.'}</q>
|
|
240
|
+
<p style={{ marginTop: 'var(--space-1-5)' }}>Your developer can fix that.</p>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
// Skipped: no one to email (Site Settings sets who), or not the production server (which alone sends email).
|
|
246
|
+
const notProduction = (email.error ?? '').startsWith('Not emailed');
|
|
247
|
+
return (
|
|
248
|
+
<div className="note" data-mail="skipped">
|
|
249
|
+
<Icon name="mail" />
|
|
250
|
+
<div>
|
|
251
|
+
<b>No email was sent</b>
|
|
252
|
+
{notProduction ? (
|
|
253
|
+
<p>{email.error}</p>
|
|
254
|
+
) : (
|
|
255
|
+
<>
|
|
256
|
+
<p>The {formLabel} form had no one to email.</p>
|
|
257
|
+
<div className="acts">
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
className="btn sm"
|
|
261
|
+
data-act="open-settings"
|
|
262
|
+
onClick={() => void editor.openDocument({ collection: 'settings', path: editor.documents.get().find((d) => d.collection === 'settings')?.path ?? 'site-settings' })}
|
|
263
|
+
>
|
|
264
|
+
Open Site Settings
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
</>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox's data in the browser (M6 Decision 9; forms/inbox.ts has the routes): the
|
|
3
|
+
* list (newest first, 50 at a time, Older by cursor), the submission open on its sheet,
|
|
4
|
+
* and the actions. `inboxFor(editor)`, one per editor:
|
|
5
|
+
*
|
|
6
|
+
* inbox.list { rows, older, loading, olderLoading, error }: last known while it's read again
|
|
7
|
+
* inbox.open the open submission's GET answer (or its error), by id
|
|
8
|
+
* inbox.load() the first page (on opening the level, and on intent: hovering its row)
|
|
9
|
+
* inbox.loadOlder() the next 50 (`?before=`)
|
|
10
|
+
* inbox.show(id) read it, and mark it read with an explicit POST …/read (Decision 9:
|
|
11
|
+
* the GET never does), when it's unread
|
|
12
|
+
* inbox.markUnread(id) / inbox.remove(id) POST …/unread and …/delete (`{ "confirm": true }`)
|
|
13
|
+
* A delete counts as done only on the route's own 204, or its JSON 404
|
|
14
|
+
* (already deleted): anything else, a proxy's 404 included, leaves the
|
|
15
|
+
* row listed and says so (Acceptance 27). One delete per id at a time.
|
|
16
|
+
*
|
|
17
|
+
* **The unread count** (`editor.submissions`) is set from every answer: a list's and a
|
|
18
|
+
* GET's `unread`, a 204's `X-Caracms-Unread`. A 503 (`submissions-unset`: the production
|
|
19
|
+
* server has no bucket) sets the `submissions-off` state.
|
|
20
|
+
*/
|
|
21
|
+
import { formatBytes } from '../../../forms/rules.ts';
|
|
22
|
+
import { ENDPOINTS, UNREAD_HEADER, type SignedOutError, type SubmissionResponse, type SubmissionRow, type SubmissionsListResponse, type SubmissionsUnset } from '../../types.ts';
|
|
23
|
+
import type { Editor } from '../editor.ts';
|
|
24
|
+
import { createStore, type Store } from '../state/store.ts';
|
|
25
|
+
|
|
26
|
+
export interface InboxList {
|
|
27
|
+
/** Null until the first page has been read. */
|
|
28
|
+
rows: SubmissionRow[] | null;
|
|
29
|
+
/** The cursor for Older, or null when there's nothing older. */
|
|
30
|
+
older: string | null;
|
|
31
|
+
loading: boolean;
|
|
32
|
+
olderLoading: boolean;
|
|
33
|
+
error: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OpenSubmission {
|
|
37
|
+
id: string;
|
|
38
|
+
answer: SubmissionResponse | null;
|
|
39
|
+
error: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Inbox {
|
|
43
|
+
list: Store<InboxList>;
|
|
44
|
+
open: Store<OpenSubmission | null>;
|
|
45
|
+
load(): Promise<void>;
|
|
46
|
+
loadOlder(): Promise<void>;
|
|
47
|
+
show(id: string): Promise<void>;
|
|
48
|
+
markUnread(id: string): Promise<{ ok: boolean; error?: string }>;
|
|
49
|
+
remove(id: string): Promise<{ ok: boolean; error?: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const inboxes = new WeakMap<Editor, Inbox>();
|
|
53
|
+
|
|
54
|
+
const COULDNT = "The submissions couldn't be read just now. Try again in a moment.";
|
|
55
|
+
const NOT_REACHED = "The server didn't answer just now, so nothing was deleted. Try again in a moment.";
|
|
56
|
+
|
|
57
|
+
/** An answer from the inbox's own route (forms/inbox.ts: JSON, or a 204), not a proxy's page. */
|
|
58
|
+
/** The count on a 204 (`X-Caracms-Unread`), or null when it's absent. */
|
|
59
|
+
const unreadHeader = (response: Response): number | null => {
|
|
60
|
+
const v = response.headers.get(UNREAD_HEADER);
|
|
61
|
+
return v !== null && /^\d+$/.test(v) ? Number(v) : null;
|
|
62
|
+
};
|
|
63
|
+
const fromRoute = (response: Response) => response.status === 204 || (response.headers.get('content-type') ?? '').includes('application/json');
|
|
64
|
+
|
|
65
|
+
export function inboxFor(editor: Editor): Inbox {
|
|
66
|
+
let inbox = inboxes.get(editor);
|
|
67
|
+
if (!inbox) inboxes.set(editor, (inbox = createInbox(editor)));
|
|
68
|
+
return inbox;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createInbox(editor: Editor): Inbox {
|
|
72
|
+
const first = editor.inboxFirst;
|
|
73
|
+
const list = createStore<InboxList>({ rows: first?.list?.items ?? null, older: first?.list?.older ?? null, loading: false, olderLoading: false, error: null });
|
|
74
|
+
const open = createStore<OpenSubmission | null>(first?.open ? { id: first.open.submission.id, answer: first.open, error: null } : null);
|
|
75
|
+
const base = ENDPOINTS.submissions;
|
|
76
|
+
const route = (id: string, action?: string) => `${base}/${encodeURIComponent(id)}${action ? `/${action}` : ''}`;
|
|
77
|
+
|
|
78
|
+
const setUnread = (n: unknown) => {
|
|
79
|
+
if (typeof n === 'number' && Number.isInteger(n) && n >= 0) editor.submissions.set({ unread: n });
|
|
80
|
+
};
|
|
81
|
+
/** An answer that isn't the data: signed out (live mode), not set up (503), or a sentence to show. */
|
|
82
|
+
async function trouble(response: Response): Promise<string> {
|
|
83
|
+
const body = (await response.json().catch(() => null)) as (Partial<SubmissionsUnset> & { error?: string }) | null;
|
|
84
|
+
if (response.status === 401 && editor.liveSession) editor.liveState.update({ signedOut: body as unknown as SignedOutError });
|
|
85
|
+
if (response.status === 503 && body?.code === 'submissions-unset') {
|
|
86
|
+
editor.submissions.set({ unread: null, error: body.error ?? '', code: 'submissions-unset', missing: body.missing ?? [] });
|
|
87
|
+
}
|
|
88
|
+
return body?.error ?? COULDNT;
|
|
89
|
+
}
|
|
90
|
+
const post = (url: string, body?: unknown) =>
|
|
91
|
+
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
92
|
+
|
|
93
|
+
/** The latest first-page read wins. */
|
|
94
|
+
let loads = 0;
|
|
95
|
+
/** Deletes on their way, by id. */
|
|
96
|
+
const removing = new Map<string, Promise<{ ok: boolean; error?: string }>>();
|
|
97
|
+
|
|
98
|
+
const inbox: Inbox = {
|
|
99
|
+
list,
|
|
100
|
+
open,
|
|
101
|
+
|
|
102
|
+
async load() {
|
|
103
|
+
const ticket = ++loads;
|
|
104
|
+
list.update({ loading: true });
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(base, { headers: { Accept: 'application/json' } });
|
|
107
|
+
if (ticket !== loads) return;
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
list.update({ loading: false, error: await trouble(response) });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const body = (await response.json()) as SubmissionsListResponse;
|
|
113
|
+
if (ticket !== loads) return;
|
|
114
|
+
setUnread(body.unread);
|
|
115
|
+
list.set({ rows: body.items, older: body.older, loading: false, olderLoading: false, error: null });
|
|
116
|
+
} catch {
|
|
117
|
+
if (ticket === loads) list.update({ loading: false, error: COULDNT });
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async loadOlder() {
|
|
122
|
+
const { older, olderLoading } = list.get();
|
|
123
|
+
if (!older || olderLoading) return;
|
|
124
|
+
list.update({ olderLoading: true });
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetch(`${base}?before=${encodeURIComponent(older)}`, { headers: { Accept: 'application/json' } });
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
list.update({ olderLoading: false, error: await trouble(response) });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const body = (await response.json()) as SubmissionsListResponse;
|
|
132
|
+
setUnread(body.unread);
|
|
133
|
+
list.set((l) => {
|
|
134
|
+
const have = new Set((l.rows ?? []).map((r) => r.id));
|
|
135
|
+
return { ...l, rows: [...(l.rows ?? []), ...body.items.filter((r) => !have.has(r.id))], older: body.older, olderLoading: false, error: null };
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
list.update({ olderLoading: false, error: COULDNT });
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async show(id) {
|
|
143
|
+
const known = open.get();
|
|
144
|
+
// The same one again keeps what's on the sheet while it's read again.
|
|
145
|
+
open.set(known?.id === id ? { ...known, error: null } : { id, answer: null, error: null });
|
|
146
|
+
let answer: SubmissionResponse;
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(route(id), { headers: { Accept: 'application/json' } });
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const error = response.status === 404 ? "There's no such submission. It may have been deleted." : await trouble(response);
|
|
151
|
+
if (open.get()?.id === id) open.set({ id, answer: null, error });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
answer = (await response.json()) as SubmissionResponse;
|
|
155
|
+
} catch {
|
|
156
|
+
if (open.get()?.id === id) open.set({ id, answer: null, error: COULDNT });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
setUnread(answer.unread);
|
|
160
|
+
if (open.get()?.id !== id) return;
|
|
161
|
+
open.set({ id, answer, error: null });
|
|
162
|
+
// Decision 9: opening it marks it read, an explicit write (the GET changed nothing).
|
|
163
|
+
if (answer.submission.readAt === null) {
|
|
164
|
+
const now = new Date().toISOString();
|
|
165
|
+
const shown = { ...answer, submission: { ...answer.submission, readAt: now } };
|
|
166
|
+
open.set({ id, answer: shown, error: null });
|
|
167
|
+
patchRow(id, { readAt: now });
|
|
168
|
+
try {
|
|
169
|
+
const response = await post(route(id, 'read'));
|
|
170
|
+
if (response.ok) setUnread(unreadHeader(response));
|
|
171
|
+
else await trouble(response);
|
|
172
|
+
} catch {
|
|
173
|
+
/* it stays unread on the server; the next list says so */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async markUnread(id) {
|
|
179
|
+
try {
|
|
180
|
+
const response = await post(route(id, 'unread'));
|
|
181
|
+
if (response.status !== 204) return { ok: false, error: await trouble(response) };
|
|
182
|
+
setUnread(unreadHeader(response));
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: false, error: COULDNT };
|
|
185
|
+
}
|
|
186
|
+
patchRow(id, { readAt: null });
|
|
187
|
+
if (open.get()?.id === id) open.set(null);
|
|
188
|
+
return { ok: true };
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
remove(id) {
|
|
192
|
+
// One delete per submission at a time: a second press (a double click) waits for the first.
|
|
193
|
+
const running = removing.get(id);
|
|
194
|
+
if (running) return running;
|
|
195
|
+
const done = (async (): Promise<{ ok: boolean; error?: string }> => {
|
|
196
|
+
try {
|
|
197
|
+
const response = await post(route(id, 'delete'), { confirm: true });
|
|
198
|
+
// Gone only on the route's own answers: its 204, or its JSON 404 (deleted already, by
|
|
199
|
+
// someone else). A 404 from anything in between (a proxy, the platform's edge while a
|
|
200
|
+
// deploy switches) never reached the route: nothing was deleted (Acceptance 27).
|
|
201
|
+
const gone = response.status === 204 || (response.status === 404 && fromRoute(response));
|
|
202
|
+
if (!gone) return { ok: false, error: response.status === 404 ? NOT_REACHED : await trouble(response) };
|
|
203
|
+
if (response.status === 204) setUnread(unreadHeader(response));
|
|
204
|
+
} catch {
|
|
205
|
+
return { ok: false, error: COULDNT };
|
|
206
|
+
}
|
|
207
|
+
list.set((l) => ({ ...l, rows: l.rows ? l.rows.filter((r) => r.id !== id) : l.rows }));
|
|
208
|
+
if (open.get()?.id === id) open.set(null);
|
|
209
|
+
// The list as the server has it now (last known stays meanwhile).
|
|
210
|
+
void inbox.load();
|
|
211
|
+
return { ok: true };
|
|
212
|
+
})();
|
|
213
|
+
removing.set(id, done);
|
|
214
|
+
void done.finally(() => removing.delete(id));
|
|
215
|
+
return done;
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function patchRow(id: string, patch: Partial<SubmissionRow>) {
|
|
220
|
+
list.set((l) => (l.rows?.some((r) => r.id === id) ? { ...l, rows: l.rows.map((r) => (r.id === id ? { ...r, ...patch } : r)) } : l));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return inbox;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── How the inbox words things ───────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/** "1 file", "2 files". */
|
|
229
|
+
export const filesLabel = (n: number) => `${n} file${n === 1 ? '' : 's'}`;
|
|
230
|
+
|
|
231
|
+
/** A file's size and kind: "1.4 MB · PDF". */
|
|
232
|
+
export function fileLine(size: number, ext: string): string {
|
|
233
|
+
return `${formatBytes(size)} · ${EXT_NAME[ext] ?? ext}`;
|
|
234
|
+
}
|
|
235
|
+
const EXT_NAME: Record<string, string> = {
|
|
236
|
+
PDF: 'PDF',
|
|
237
|
+
JPG: 'JPEG image',
|
|
238
|
+
PNG: 'PNG image',
|
|
239
|
+
WEBP: 'WebP image',
|
|
240
|
+
HEIC: 'HEIC image',
|
|
241
|
+
HEIF: 'HEIF image',
|
|
242
|
+
DOCX: 'Word document',
|
|
243
|
+
DOC: 'Word document',
|
|
244
|
+
XLSX: 'Excel spreadsheet',
|
|
245
|
+
XLS: 'Excel spreadsheet',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const safeZone = (timeZone: string) => {
|
|
249
|
+
try {
|
|
250
|
+
new Intl.DateTimeFormat('en-GB', { timeZone });
|
|
251
|
+
return timeZone;
|
|
252
|
+
} catch {
|
|
253
|
+
return 'UTC';
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
function dayParts(iso: string | Date, timeZone: string) {
|
|
257
|
+
const f = new Intl.DateTimeFormat('en-GB', { timeZone, year: 'numeric', month: 'short', day: 'numeric', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
258
|
+
const out: Record<string, string> = {};
|
|
259
|
+
for (const p of f.formatToParts(typeof iso === 'string' ? new Date(iso) : iso)) out[p.type] = p.value;
|
|
260
|
+
return out as { year: string; month: string; day: string; weekday: string; hour: string; minute: string };
|
|
261
|
+
}
|
|
262
|
+
const dayKey = (p: { year: string; month: string; day: string }) => `${p.year}-${p.month}-${p.day}`;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A row's time (the mockup's): "14:12" today, "Yesterday", "Tue 13 Oct", and another
|
|
266
|
+
* year's "13 Oct 2025". In the site's zone.
|
|
267
|
+
*/
|
|
268
|
+
export function rowTime(iso: string, timeZone: string, now: Date = new Date()): string {
|
|
269
|
+
const zone = safeZone(timeZone);
|
|
270
|
+
const p = dayParts(iso, zone);
|
|
271
|
+
const n = dayParts(now, zone);
|
|
272
|
+
if (dayKey(p) === dayKey(n)) return `${p.hour}:${p.minute}`;
|
|
273
|
+
const yesterday = dayParts(new Date(now.getTime() - 86_400_000), zone);
|
|
274
|
+
if (dayKey(p) === dayKey(yesterday)) return 'Yesterday';
|
|
275
|
+
return p.year === n.year ? `${p.weekday} ${p.day} ${p.month}` : `${p.day} ${p.month} ${p.year}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The zone as people say it: "Ireland Time", "New Zealand Time"; UTC as "UTC". */
|
|
279
|
+
export function zoneName(timeZone: string): string {
|
|
280
|
+
const zone = safeZone(timeZone);
|
|
281
|
+
if (zone === 'UTC' || zone === 'Etc/UTC') return 'UTC';
|
|
282
|
+
try {
|
|
283
|
+
const name = new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeZoneName: 'longGeneric' }).formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value;
|
|
284
|
+
if (name && !/^GMT[+-]/.test(name)) return name;
|
|
285
|
+
} catch {
|
|
286
|
+
/* below */
|
|
287
|
+
}
|
|
288
|
+
return zone;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The sheet's full time: "Thursday 15 October 2026, 14:12 Ireland Time". */
|
|
292
|
+
export function fullTime(iso: string, timeZone: string): string {
|
|
293
|
+
const zone = safeZone(timeZone);
|
|
294
|
+
const f = new Intl.DateTimeFormat('en-GB', { timeZone: zone, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
295
|
+
const p: Record<string, string> = {};
|
|
296
|
+
for (const part of f.formatToParts(new Date(iso))) p[part.type] = part.value;
|
|
297
|
+
return `${p.weekday} ${p.day} ${p.month} ${p.year}, ${p.hour}:${p.minute} ${zoneName(zone)}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** "14:12" (today) or "15 Oct, 14:12" for the email's line: when it went. */
|
|
301
|
+
export function shortTime(iso: string, timeZone: string, now: Date = new Date()): string {
|
|
302
|
+
const zone = safeZone(timeZone);
|
|
303
|
+
const p = dayParts(iso, zone);
|
|
304
|
+
const n = dayParts(now, zone);
|
|
305
|
+
return dayKey(p) === dayKey(n) ? `${p.hour}:${p.minute}` : `${p.day} ${p.month}${p.year === n.year ? '' : ` ${p.year}`}, ${p.hour}:${p.minute}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Reply by email (Decision 9): the visitor's address percent-encoded, so a crafted one can't add recipients or headers. */
|
|
309
|
+
export function replyHref(address: string, brandName: string): string {
|
|
310
|
+
return `mailto:${encodeURIComponent(address)}?subject=${encodeURIComponent(`Re: your enquiry to ${brandName}`)}`;
|
|
311
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* People and times as live mode shows them (M4 Decision 16; M2 Decision 18).
|
|
3
|
+
*
|
|
4
|
+
* - **Times are absolute, in the site's time zone** (`StatusResponse.timeZone`):
|
|
5
|
+
* "14:20", "Today 14:20", "12 Oct, 09:30" (another year adds it). "Today" is the
|
|
6
|
+
* server's day (`status.now`), so the server's render and the browser's agree.
|
|
7
|
+
* History names the zone once (`zoneLine`).
|
|
8
|
+
* - **People**: a name is the person's own, else the part of the address before `@`
|
|
9
|
+
* (live/messages.ts → displayName); the avatar's colour comes from the address, so
|
|
10
|
+
* a person is the same colour everywhere.
|
|
11
|
+
*/
|
|
12
|
+
import { displayName } from '../../../live/messages.ts';
|
|
13
|
+
import type { Person } from '../../types.ts';
|
|
14
|
+
|
|
15
|
+
export { displayName };
|
|
16
|
+
|
|
17
|
+
const safeZone = (timeZone: string) => {
|
|
18
|
+
try {
|
|
19
|
+
new Intl.DateTimeFormat('en-GB', { timeZone });
|
|
20
|
+
return timeZone;
|
|
21
|
+
} catch {
|
|
22
|
+
return 'UTC';
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function parts(iso: string, timeZone: string) {
|
|
27
|
+
const f = new Intl.DateTimeFormat('en-GB', { timeZone: safeZone(timeZone), year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
28
|
+
const out: Record<string, string> = {};
|
|
29
|
+
for (const p of f.formatToParts(new Date(iso))) out[p.type] = p.value;
|
|
30
|
+
return out as { year: string; month: string; day: string; hour: string; minute: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "14:20" in the site's zone. */
|
|
34
|
+
export function clock(iso: string, timeZone: string): string {
|
|
35
|
+
const p = parts(iso, timeZone);
|
|
36
|
+
return `${p.hour}:${p.minute}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "Today 14:20", "12 Oct, 09:30", "12 Oct 2025, 09:30": absolute, in the site's zone. */
|
|
40
|
+
export function when(iso: string, timeZone: string, now: string): string {
|
|
41
|
+
const p = parts(iso, timeZone);
|
|
42
|
+
const n = parts(now, timeZone);
|
|
43
|
+
if (p.year === n.year && p.month === n.month && p.day === n.day) return `Today ${p.hour}:${p.minute}`;
|
|
44
|
+
return `${p.day} ${p.month}${p.year === n.year ? '' : ` ${p.year}`}, ${p.hour}:${p.minute}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** History's one line naming the zone: "Times are Ireland Time (Europe/Dublin)." */
|
|
48
|
+
export function zoneLine(timeZone: string): string {
|
|
49
|
+
const zone = safeZone(timeZone);
|
|
50
|
+
if (zone === 'UTC') return 'Times are UTC.';
|
|
51
|
+
try {
|
|
52
|
+
const name = new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeZoneName: 'longGeneric' }).formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value;
|
|
53
|
+
if (name && name !== zone) return `Times are ${name} (${zone}).`;
|
|
54
|
+
} catch {
|
|
55
|
+
/* below */
|
|
56
|
+
}
|
|
57
|
+
return `Times are in ${zone}.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** "Home", "Home and About", "Home, About and Contact". */
|
|
61
|
+
export function joinNames(names: readonly string[]): string {
|
|
62
|
+
if (names.length < 2) return names.join('');
|
|
63
|
+
return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const COLOURS = ['var(--p-niamh)', 'var(--p-aoife)', 'var(--p-ciaran)'];
|
|
67
|
+
|
|
68
|
+
/** A person's avatar colour (one of the mockup's three), the same for them everywhere. */
|
|
69
|
+
export function colourOf(person: Person): string {
|
|
70
|
+
let h = 0;
|
|
71
|
+
for (const c of person.email.toLowerCase()) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
72
|
+
return COLOURS[h % COLOURS.length]!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const initialOf = (person: Person) => (displayName(person).trim()[0] ?? '?').toUpperCase();
|
|
76
|
+
|
|
77
|
+
export const samePerson = (a: Person | null | undefined, b: Person | null | undefined) => !!a && !!b && a.email.toLowerCase() === b.email.toLowerCase();
|