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,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Settings' publish-time keys (M6 Decision 14; the mockup's #settings-m6), after
|
|
3
|
+
* Editor branding. Unlike the sections above they act on the live site.
|
|
4
|
+
*
|
|
5
|
+
* - **Search and sharing**: the defaults (`seo`): a title (the home page's, and the
|
|
6
|
+
* fallback's brand), a description, a share image with no alt input. M5a's soft limits.
|
|
7
|
+
* - **Google tag** (`googleTag`, Decision 16): one ID, checked as it's typed with the
|
|
8
|
+
* server's own rule (seo.ts → googleTagKind). A pasted Google script, or anything else
|
|
9
|
+
* with an ID in it, offers "Use G-…" (googleTagIn); anything that isn't an ID says
|
|
10
|
+
* nothing goes on the site.
|
|
11
|
+
* - **Form emails** (`formRecipients`): one list per form in the config, labelled by the
|
|
12
|
+
* form's label. Addresses are trimmed, a duplicate isn't added, and an invalid one is
|
|
13
|
+
* kept as typed with an amber "Gets no email" (the email skips it, as the server's
|
|
14
|
+
* check does: rules.ts → looksLikeEmail). Removing one has Undo.
|
|
15
|
+
* - **Redirects** (`redirects`, pre-M7 item 8): each old address and where it goes, removable.
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from 'react';
|
|
18
|
+
import { looksLikeEmail } from '../../../../forms/rules.ts';
|
|
19
|
+
import { parseRedirects, type Redirect } from '../../../../redirects.ts';
|
|
20
|
+
import { sameAddress } from '../../../../slug.ts';
|
|
21
|
+
import { googleTagIn, googleTagKind, SEO_DESCRIPTION_SOFT_LIMIT, SEO_TITLE_SOFT_LIMIT } from '../../../../seo.ts';
|
|
22
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
23
|
+
import { useHost } from '../../fields/host.tsx';
|
|
24
|
+
import { DESC_WHY, ShareImage, SoftText, TITLE_WHY } from '../../fields/SeoGroup.tsx';
|
|
25
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
26
|
+
import type { EditorForm } from '../../../types.ts';
|
|
27
|
+
|
|
28
|
+
const asObject = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
|
|
29
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
30
|
+
|
|
31
|
+
export function SettingsM6({ data }: { data: Record<string, unknown> }) {
|
|
32
|
+
const editor = useEditor();
|
|
33
|
+
const host = useHost();
|
|
34
|
+
const brand = useStore(editor.brand, (b) => b.name);
|
|
35
|
+
const seo = asObject(data.seo);
|
|
36
|
+
const name = str(asObject(data.brand).name).trim() || brand;
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<section className="set-sec" data-sec="seo">
|
|
40
|
+
<h3>Search and sharing</h3>
|
|
41
|
+
<p className="lead">Defaults for pages that don’t set their own.</p>
|
|
42
|
+
<SoftText
|
|
43
|
+
scope={host.scope}
|
|
44
|
+
name="seo.title"
|
|
45
|
+
label="Default title"
|
|
46
|
+
value={str(seo.title)}
|
|
47
|
+
max={SEO_TITLE_SOFT_LIMIT}
|
|
48
|
+
placeholder={name}
|
|
49
|
+
help={`Blank is “${name}”. Other pages show their own title, then “· ${name}”.`}
|
|
50
|
+
overWhy={TITLE_WHY}
|
|
51
|
+
disabled={host.readOnly}
|
|
52
|
+
onChange={(v) => host.set('seo.title', v)}
|
|
53
|
+
/>
|
|
54
|
+
<SoftText
|
|
55
|
+
scope={host.scope}
|
|
56
|
+
name="seo.description"
|
|
57
|
+
label="Default description"
|
|
58
|
+
value={str(seo.description)}
|
|
59
|
+
max={SEO_DESCRIPTION_SOFT_LIMIT}
|
|
60
|
+
multiline
|
|
61
|
+
overWhy={DESC_WHY}
|
|
62
|
+
disabled={host.readOnly}
|
|
63
|
+
onChange={(v) => host.set('seo.description', v.replace(/\n+/g, ' '))}
|
|
64
|
+
/>
|
|
65
|
+
<ShareImage name="seo.image" label="Default share image" value={seo.image ?? null} help="1200 × 630." />
|
|
66
|
+
</section>
|
|
67
|
+
<GoogleTag value={str(data.googleTag)} />
|
|
68
|
+
<section className="set-sec" data-sec="forms">
|
|
69
|
+
<h3>Form emails</h3>
|
|
70
|
+
<p className="lead">Every message is also kept in Submissions.</p>
|
|
71
|
+
{editor.forms.length ? (
|
|
72
|
+
editor.forms.map((form) => <Recipients key={form.id} form={form} list={recipientsOf(data, form.id)} />)
|
|
73
|
+
) : (
|
|
74
|
+
<p className="field-help">No forms yet.</p>
|
|
75
|
+
)}
|
|
76
|
+
</section>
|
|
77
|
+
<Redirects list={parseRedirects(data.redirects)} />
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pre-M7 item 8: the redirects that changing a page's address adds (`redirects`), each
|
|
84
|
+
* "from, to", removable with Undo. One whose old address has a page again (it shows
|
|
85
|
+
* instead), or whose new one has none, says so on a warning wash.
|
|
86
|
+
*/
|
|
87
|
+
function Redirects({ list }: { list: Redirect[] }) {
|
|
88
|
+
const editor = useEditor();
|
|
89
|
+
const host = useHost();
|
|
90
|
+
const documents = useStore(editor.documents);
|
|
91
|
+
const has = (url: string) => documents.some((d) => d.url !== null && sameAddress(d.url) === sameAddress(url));
|
|
92
|
+
return (
|
|
93
|
+
<section className="set-sec" data-sec="redirects">
|
|
94
|
+
<h3>Redirects</h3>
|
|
95
|
+
<p className="lead">Old addresses that send visitors to new ones.</p>
|
|
96
|
+
{list.length ? (
|
|
97
|
+
<div className="eds" data-path="redirects">
|
|
98
|
+
{list.map((r, i) => {
|
|
99
|
+
const why = has(r.from) ? 'A page is at this address now, so the page shows instead.' : !has(r.to) ? 'No page is at the address it goes to.' : null;
|
|
100
|
+
return (
|
|
101
|
+
<div key={`${i}:${r.from}`} className={`ed${why ? ' is-confirm' : ''}`} data-redirect={r.from}>
|
|
102
|
+
<span className="tile" aria-hidden="true">
|
|
103
|
+
<Icon name="arrow-r" size="sm" />
|
|
104
|
+
</span>
|
|
105
|
+
<span className="t">
|
|
106
|
+
<b>{r.from}</b>
|
|
107
|
+
<small>
|
|
108
|
+
to <span className="mono">{r.to}</span>
|
|
109
|
+
</small>
|
|
110
|
+
</span>
|
|
111
|
+
<button type="button" className="ib" aria-label={`Remove the redirect from ${r.from}`} disabled={host.readOnly} onClick={() => host.removeItem('redirects', i, `Removed the redirect from ${r.from}`)}>
|
|
112
|
+
<Icon name="x" size="sm" />
|
|
113
|
+
</button>
|
|
114
|
+
{why ? (
|
|
115
|
+
<div className="ed-q" data-why="redirect">
|
|
116
|
+
{why}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<p className="field-help">None yet.</p>
|
|
125
|
+
)}
|
|
126
|
+
</section>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const recipientsOf = (data: Record<string, unknown>, id: string): string[] => {
|
|
131
|
+
const list = asObject(data.formRecipients)[id];
|
|
132
|
+
return Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** What the tag field says under it, by the server's own rule (googleTagKind), as it's typed. */
|
|
136
|
+
export function googleTagMessage(raw: string): { bad: boolean; text: string; kind: 'blank' | 'ok' | 'bad'; fix: string | null } {
|
|
137
|
+
const v = raw.trim();
|
|
138
|
+
if (!v) return { bad: false, kind: 'blank', text: 'Blank: the site has no tag.', fix: null };
|
|
139
|
+
const kind = googleTagKind(v);
|
|
140
|
+
if (kind) return { bad: false, kind: 'ok', text: `${kind === 'gtm' ? 'Loads Google Tag Manager' : 'Loads Google Analytics'} on the live site`, fix: null };
|
|
141
|
+
const fix = googleTagIn(v);
|
|
142
|
+
const text = /<script|googletagmanager|gtag\(/i.test(v)
|
|
143
|
+
? 'Paste only the ID (G-… or GTM-…), not the script.'
|
|
144
|
+
: /^UA-/i.test(v)
|
|
145
|
+
? 'UA- IDs are Universal Analytics, which Google has switched off. Use the G- ID from Google Analytics 4.'
|
|
146
|
+
: 'IDs start G- or GTM-.';
|
|
147
|
+
return { bad: true, kind: 'bad', text, fix };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function GoogleTag({ value }: { value: string }) {
|
|
151
|
+
const host = useHost();
|
|
152
|
+
const msg = googleTagMessage(value);
|
|
153
|
+
return (
|
|
154
|
+
<section className="set-sec" data-sec="gtag">
|
|
155
|
+
<h3>Google tag</h3>
|
|
156
|
+
<p className="lead">Live site only.</p>
|
|
157
|
+
<div className="field gtag" data-field="googleTag" data-ff="googleTag">
|
|
158
|
+
<div className="field-head">
|
|
159
|
+
<label htmlFor="set-gtag">Tag ID</label>
|
|
160
|
+
</div>
|
|
161
|
+
<input
|
|
162
|
+
className="input mono"
|
|
163
|
+
id="set-gtag"
|
|
164
|
+
data-path="googleTag"
|
|
165
|
+
value={value}
|
|
166
|
+
placeholder="G-XXXXXXXXXX or GTM-XXXXXXX"
|
|
167
|
+
autoComplete="off"
|
|
168
|
+
spellCheck={false}
|
|
169
|
+
aria-describedby="gtag-w"
|
|
170
|
+
aria-invalid={msg.bad ? 'true' : undefined}
|
|
171
|
+
disabled={host.readOnly}
|
|
172
|
+
onChange={(e) => host.set('googleTag', e.target.value.replace(/\n+/g, ' '))}
|
|
173
|
+
/>
|
|
174
|
+
<div id="gtag-w" data-gtag={msg.kind}>
|
|
175
|
+
{msg.kind === 'blank' ? (
|
|
176
|
+
<p className="field-help" style={{ marginTop: 'var(--space-1-5)' }}>
|
|
177
|
+
{msg.text}
|
|
178
|
+
</p>
|
|
179
|
+
) : msg.kind === 'ok' ? (
|
|
180
|
+
<p className="field-ok">
|
|
181
|
+
<Icon name="check" size="xs" />
|
|
182
|
+
{msg.text}
|
|
183
|
+
</p>
|
|
184
|
+
) : (
|
|
185
|
+
<p className="field-warn">
|
|
186
|
+
<Icon name="warn" size="sm" />
|
|
187
|
+
<span>
|
|
188
|
+
{msg.text}
|
|
189
|
+
{msg.fix ? (
|
|
190
|
+
<button type="button" className="linkbtn" data-act="gtag-fix" disabled={host.readOnly} onClick={() => host.set('googleTag', msg.fix)}>
|
|
191
|
+
Use {msg.fix}
|
|
192
|
+
</button>
|
|
193
|
+
) : null}
|
|
194
|
+
</span>
|
|
195
|
+
</p>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</section>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function Recipients({ form, list }: { form: EditorForm; list: string[] }) {
|
|
204
|
+
const editor = useEditor();
|
|
205
|
+
const host = useHost();
|
|
206
|
+
const [adding, setAdding] = useState('');
|
|
207
|
+
const [why, setWhy] = useState('');
|
|
208
|
+
const n = list.filter(looksLikeEmail).length;
|
|
209
|
+
const path = `formRecipients.${form.id}`;
|
|
210
|
+
const typed = adding.trim();
|
|
211
|
+
// Checked as it's typed: once it has an @, an address that won't get email says so before it's added.
|
|
212
|
+
const typedBad = typed.includes('@') && !looksLikeEmail(typed);
|
|
213
|
+
const add = () => {
|
|
214
|
+
if (!typed || host.readOnly) return;
|
|
215
|
+
if (list.some((x) => x.trim().toLowerCase() === typed.toLowerCase())) {
|
|
216
|
+
setWhy(`${typed} is already on the list.`);
|
|
217
|
+
setAdding('');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
host.set(path, [...list, typed]);
|
|
221
|
+
setAdding('');
|
|
222
|
+
setWhy('');
|
|
223
|
+
editor.announce(looksLikeEmail(typed) ? `Added ${typed}.` : `Added ${typed}. It isn't a whole email address, so it gets no email.`);
|
|
224
|
+
};
|
|
225
|
+
return (
|
|
226
|
+
<div className="field rcpt" data-field={`rcpt-${form.id}`} data-ff={path}>
|
|
227
|
+
<div className="field-head">
|
|
228
|
+
<span className="field-label">{form.label} form</span>
|
|
229
|
+
<span className="field-meta">{n ? `${n} address${n === 1 ? '' : 'es'}` : 'No one'}</span>
|
|
230
|
+
</div>
|
|
231
|
+
{list.length ? (
|
|
232
|
+
<div className="eds" data-path={path}>
|
|
233
|
+
{list.map((value, i) => {
|
|
234
|
+
const bad = !looksLikeEmail(value);
|
|
235
|
+
return (
|
|
236
|
+
<div key={`${i}:${value}`} className={`ed${bad ? ' is-confirm' : ''}`} data-recipient={value} data-warned={bad ? '' : undefined}>
|
|
237
|
+
<span className="tile" aria-hidden="true">
|
|
238
|
+
<Icon name={bad ? 'warn' : 'mail'} size="sm" />
|
|
239
|
+
</span>
|
|
240
|
+
<span className="t">
|
|
241
|
+
<b>{value}</b>
|
|
242
|
+
{bad ? <small style={{ color: 'var(--status-warning)' }}>Gets no email</small> : <small>Gets every message</small>}
|
|
243
|
+
</span>
|
|
244
|
+
<button type="button" className="ib" aria-label={`Remove ${value}`} disabled={host.readOnly} onClick={() => host.removeItem(path, i, `Removed ${value}`)}>
|
|
245
|
+
<Icon name="x" size="sm" />
|
|
246
|
+
</button>
|
|
247
|
+
{bad ? (
|
|
248
|
+
<div className="ed-q" style={{ color: 'var(--status-warning)' }} data-why="recipient">
|
|
249
|
+
Not a full address; gets no email.
|
|
250
|
+
</div>
|
|
251
|
+
) : null}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
</div>
|
|
256
|
+
) : (
|
|
257
|
+
<div className="list-empty">No one yet.</div>
|
|
258
|
+
)}
|
|
259
|
+
<form
|
|
260
|
+
className="ed-add"
|
|
261
|
+
data-form="rcpt"
|
|
262
|
+
data-fid={form.id}
|
|
263
|
+
onSubmit={(e) => {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
add();
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<input
|
|
269
|
+
className="input"
|
|
270
|
+
data-rcpt={form.id}
|
|
271
|
+
value={adding}
|
|
272
|
+
placeholder="name@example.com"
|
|
273
|
+
aria-label={`Add an address for the ${form.label} form’s email`}
|
|
274
|
+
autoComplete="off"
|
|
275
|
+
spellCheck={false}
|
|
276
|
+
aria-describedby={typedBad ? `rcpt-${form.id}-w` : undefined}
|
|
277
|
+
onChange={(e) => {
|
|
278
|
+
setAdding(e.target.value);
|
|
279
|
+
if (why) setWhy('');
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
<button className="btn" type="submit">
|
|
283
|
+
Add
|
|
284
|
+
</button>
|
|
285
|
+
</form>
|
|
286
|
+
{typedBad ? (
|
|
287
|
+
<p className="field-warn" id={`rcpt-${form.id}-w`}>
|
|
288
|
+
<Icon name="warn" size="sm" />
|
|
289
|
+
<span>That isn’t a whole email address yet, so it would get no email.</span>
|
|
290
|
+
</p>
|
|
291
|
+
) : why ? (
|
|
292
|
+
<p className="field-warn quiet">
|
|
293
|
+
<Icon name="check" size="sm" />
|
|
294
|
+
<span>{why}</span>
|
|
295
|
+
</p>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox level, Site-wide → Submissions (M6 Decision 9; the mockup's #submissions,
|
|
3
|
+
* #submissions-older, #submissions-empty, #submissions-off): `view.level === 'submissions'`.
|
|
4
|
+
*
|
|
5
|
+
* - **The list**, newest first, 50 at a time with Older (by cursor). A row: the unread
|
|
6
|
+
* mark (a dot and a heavier name, never a side rail), who sent it, when (the site's
|
|
7
|
+
* zone: "14:12" today, then "Yesterday" or "Tue 13 Oct"), a one-line snippet, the
|
|
8
|
+
* form, the file count, and **Email failed** when its email failed. The open one has
|
|
9
|
+
* the selection's wash and ring.
|
|
10
|
+
* - **A row opens the submission** on a sheet over the dimmed page (inbox/InboxStage.tsx),
|
|
11
|
+
* with the list kept here; the address follows (`/admin/submissions/<id>`).
|
|
12
|
+
* - **Not set up** (the production server has no bucket): the amber note naming the
|
|
13
|
+
* variables, since a live form then fails for visitors.
|
|
14
|
+
* - Last-known rows while the list is read again (on opening, and on intent from the
|
|
15
|
+
* Site-wide row); the first paint of `/admin/submissions` has them (admin-props.ts).
|
|
16
|
+
*
|
|
17
|
+
* Everything a visitor typed is React text: never HTML.
|
|
18
|
+
*/
|
|
19
|
+
import { Fragment, useEffect } from 'react';
|
|
20
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
21
|
+
import { inboxFor, filesLabel, rowTime } from '../../inbox/store.ts';
|
|
22
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
23
|
+
import type { SubmissionRow } from '../../../types.ts';
|
|
24
|
+
import { CurrentHead, DocumentsTab, GlobalNotes, RecentStrip, Trail } from '../parts.tsx';
|
|
25
|
+
|
|
26
|
+
/** Where the inbox's not-set-up note and the documents level read whether it's off. */
|
|
27
|
+
export function useSubmissionsOff() {
|
|
28
|
+
const editor = useEditor();
|
|
29
|
+
return useStore(editor.submissions, (s) => (s.unread === null ? s : null));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SubmissionsLevel() {
|
|
33
|
+
const editor = useEditor();
|
|
34
|
+
const inbox = inboxFor(editor);
|
|
35
|
+
const rows = useStore(inbox.list, (l) => l.rows);
|
|
36
|
+
const older = useStore(inbox.list, (l) => l.older);
|
|
37
|
+
const olderLoading = useStore(inbox.list, (l) => l.olderLoading);
|
|
38
|
+
const error = useStore(inbox.list, (l) => l.error);
|
|
39
|
+
const unread = useStore(editor.submissions, (s) => s.unread ?? 0);
|
|
40
|
+
const off = useSubmissionsOff();
|
|
41
|
+
const openId = useStore(editor.view, (v) => v.inbox?.id ?? null);
|
|
42
|
+
const timeZone = editor.timeZone();
|
|
43
|
+
|
|
44
|
+
// The list read again on opening (last known stays on screen meanwhile); the open one read, and marked read.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void inbox.load();
|
|
47
|
+
}, [inbox]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (openId) void inbox.show(openId);
|
|
50
|
+
}, [inbox, openId]);
|
|
51
|
+
|
|
52
|
+
let body;
|
|
53
|
+
if (off) body = <SubmissionsOffNote off={off} />;
|
|
54
|
+
else if (rows && rows.length === 0)
|
|
55
|
+
body = (
|
|
56
|
+
<div className="empty">
|
|
57
|
+
<b>No messages yet</b>
|
|
58
|
+
<button type="button" className="linkbtn" style={{ marginTop: 'var(--space-1)' }} data-act="open-settings" onClick={() => void editor.openDocument({ collection: 'settings', path: settingsPath(editor) })}>
|
|
59
|
+
Who gets the email
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
else if (rows)
|
|
64
|
+
body = (
|
|
65
|
+
<>
|
|
66
|
+
<div className="srows">
|
|
67
|
+
{rows.map((row) => (
|
|
68
|
+
<Row key={row.id} row={row} current={row.id === openId} timeZone={timeZone} onOpen={() => editor.openInbox(row.id)} />
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
{older || rows.length > 50 ? (
|
|
72
|
+
<div className="subs-foot">
|
|
73
|
+
{older ? (
|
|
74
|
+
<button type="button" className="btn sm block" data-act="subs-older" disabled={olderLoading} onClick={() => void inbox.loadOlder()}>
|
|
75
|
+
Older
|
|
76
|
+
</button>
|
|
77
|
+
) : (
|
|
78
|
+
<p className="kb-hint">No older messages.</p>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
) : null}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<Trail>
|
|
87
|
+
<DocumentsTab />
|
|
88
|
+
<CurrentHead
|
|
89
|
+
back={{ label: 'Back to documents', onClick: () => editor.goTo('documents') }}
|
|
90
|
+
title="Submissions"
|
|
91
|
+
chips={unread && !off ? <span className="n-chip">{unread} unread</span> : null}
|
|
92
|
+
/>
|
|
93
|
+
</Trail>
|
|
94
|
+
<div className="side-body" data-inbox="">
|
|
95
|
+
<GlobalNotes />
|
|
96
|
+
{error && !off ? (
|
|
97
|
+
<div className="note warn tight" role="status" data-note="inbox-error">
|
|
98
|
+
<Icon name="warn" />
|
|
99
|
+
<div>
|
|
100
|
+
<p>{error}</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
{body}
|
|
105
|
+
</div>
|
|
106
|
+
<RecentStrip />
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const settingsPath = (editor: ReturnType<typeof useEditor>) => editor.documents.get().find((d) => d.collection === 'settings')?.path ?? 'site-settings';
|
|
112
|
+
|
|
113
|
+
function Row({ row, current, timeZone, onOpen }: { row: SubmissionRow; current: boolean; timeZone: string; onOpen: () => void }) {
|
|
114
|
+
const unread = row.readAt === null;
|
|
115
|
+
const failed = row.emailStatus === 'failed';
|
|
116
|
+
const time = rowTime(row.receivedAt, timeZone);
|
|
117
|
+
const label = [row.name, `${row.formLabel} form`, time, unread ? 'unread' : '', row.files ? filesLabel(row.files) : '', failed ? 'email failed' : ''].filter(Boolean).join(', ');
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className={`srow${unread ? ' is-unread' : ''}${current ? ' is-current' : ''}`}
|
|
122
|
+
data-id={row.id}
|
|
123
|
+
aria-current={current ? 'true' : undefined}
|
|
124
|
+
aria-label={label}
|
|
125
|
+
onClick={onOpen}
|
|
126
|
+
>
|
|
127
|
+
<span className="ud" aria-hidden="true" />
|
|
128
|
+
<span>
|
|
129
|
+
<span className="l1">
|
|
130
|
+
<span className="who">{row.name}</span>
|
|
131
|
+
<span className="tm">{time}</span>
|
|
132
|
+
</span>
|
|
133
|
+
<span className="sn">{row.snippet ? row.snippet : <span style={{ color: 'var(--text-subtle)' }}>No message</span>}</span>
|
|
134
|
+
<span className="l3">
|
|
135
|
+
<span>{row.formLabel}</span>
|
|
136
|
+
{row.files ? (
|
|
137
|
+
<span>
|
|
138
|
+
<Icon name="attach" />
|
|
139
|
+
{filesLabel(row.files)}
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
{failed ? (
|
|
143
|
+
<span className="chip danger">
|
|
144
|
+
<Icon name="warn" size="xs" />
|
|
145
|
+
Email failed
|
|
146
|
+
</span>
|
|
147
|
+
) : null}
|
|
148
|
+
</span>
|
|
149
|
+
</span>
|
|
150
|
+
</button>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `submissions-off`: the amber note naming what's missing, or why the count couldn't be read. */
|
|
155
|
+
export function SubmissionsOffNote({ off }: { off: { error: string; code: string; missing: string[] } }) {
|
|
156
|
+
return (
|
|
157
|
+
<div className="note warn tight" role="status" data-note="submissions-off">
|
|
158
|
+
<Icon name="inbox" />
|
|
159
|
+
<div>
|
|
160
|
+
{off.code === 'submissions-unset' ? (
|
|
161
|
+
<>
|
|
162
|
+
<b>Submissions aren’t set up on this server yet</b>
|
|
163
|
+
<p>
|
|
164
|
+
It needs{' '}
|
|
165
|
+
{off.missing.length
|
|
166
|
+
? off.missing.map((v, i) => (
|
|
167
|
+
<Fragment key={v}>
|
|
168
|
+
{i === 0 ? '' : i === off.missing.length - 1 ? ' and ' : ', '}
|
|
169
|
+
<span className="mono">{v}</span>
|
|
170
|
+
</Fragment>
|
|
171
|
+
))
|
|
172
|
+
: 'its bucket’s variables'}
|
|
173
|
+
. Until then, forms can’t take messages.
|
|
174
|
+
</p>
|
|
175
|
+
</>
|
|
176
|
+
) : (
|
|
177
|
+
<>
|
|
178
|
+
<b>Submissions can’t be read just now</b>
|
|
179
|
+
<p>{off.error}</p>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pieces every pane level is built from (the mockup's sidebar components): the
|
|
3
|
+
* trail with the stacked level tabs, the current level's header, the notes every
|
|
4
|
+
* level shows, and the Recent strip at the foot. A level is
|
|
5
|
+
*
|
|
6
|
+
* <>
|
|
7
|
+
* <Trail>{level tabs}<CurrentHead … /></Trail>
|
|
8
|
+
* <div className="side-body"><GlobalNotes />…</div>
|
|
9
|
+
* <RecentStrip />
|
|
10
|
+
* </>
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
14
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
15
|
+
import { docId } from '../../types.ts';
|
|
16
|
+
import { useTooLong } from '../nav/DocRow.tsx';
|
|
17
|
+
import { DocMarksView } from '../live/marks.tsx';
|
|
18
|
+
import { OfflineExtras } from '../live/session.tsx';
|
|
19
|
+
|
|
20
|
+
export function Trail({ children }: { children: ReactNode }) {
|
|
21
|
+
return <div className="trail">{children}</div>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A stacked level tab: the level you came from (`.lv`). */
|
|
25
|
+
export function LevelTab({ name, kind, count, onClick }: { name: string; kind: string; count?: string; onClick: () => void }) {
|
|
26
|
+
return (
|
|
27
|
+
<button type="button" className="lv" onClick={onClick}>
|
|
28
|
+
<b>{name}</b>
|
|
29
|
+
<span>· {kind}</span>
|
|
30
|
+
{count !== undefined ? <span className="n">{count}</span> : null}
|
|
31
|
+
</button>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The documents level's tab, for the levels below it. */
|
|
36
|
+
export function DocumentsTab() {
|
|
37
|
+
const editor = useEditor();
|
|
38
|
+
const count = useStore(editor.documents, (d) => d.length);
|
|
39
|
+
const name = useStore(editor.brand, (b) => b.name);
|
|
40
|
+
return <LevelTab name={name} kind="Documents" count={String(count)} onClick={() => editor.goTo('documents')} />;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The current level's header (`.cur`): back, a crumb, the title, chips, a sub line, a menu and extras. */
|
|
44
|
+
export function CurrentHead(props: {
|
|
45
|
+
back?: { label: string; onClick: () => void };
|
|
46
|
+
crumb?: string;
|
|
47
|
+
title: ReactNode;
|
|
48
|
+
chips?: ReactNode;
|
|
49
|
+
sub?: ReactNode;
|
|
50
|
+
menu?: ReactNode;
|
|
51
|
+
extra?: ReactNode;
|
|
52
|
+
lead?: ReactNode;
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="cur">
|
|
56
|
+
<div className="cur-row">
|
|
57
|
+
{props.back ? (
|
|
58
|
+
<button type="button" className="ib boxed" aria-label={props.back.label} onClick={props.back.onClick}>
|
|
59
|
+
<Icon name="chev-l" />
|
|
60
|
+
</button>
|
|
61
|
+
) : null}
|
|
62
|
+
{props.lead}
|
|
63
|
+
<div className="cur-title">
|
|
64
|
+
<span className="t">
|
|
65
|
+
{props.crumb ? <span className="crumbp">{props.crumb} /</span> : null}
|
|
66
|
+
<span>{props.title}</span>
|
|
67
|
+
{props.chips}
|
|
68
|
+
</span>
|
|
69
|
+
{props.sub ? <span className="sub">{props.sub}</span> : null}
|
|
70
|
+
</div>
|
|
71
|
+
{props.menu}
|
|
72
|
+
</div>
|
|
73
|
+
{props.extra}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Notes every level shows above its content: "Can't reach the server" (Decision 6),
|
|
80
|
+
* or, when the server answered a write with a refusal (a 4xx: the edits don't fit
|
|
81
|
+
* their base, the file was renamed or deleted while open), its own message. M3
|
|
82
|
+
* retries with the next change or a reload (no timer), so M4's countdown, "Try now"
|
|
83
|
+
* and unsent count are slots without data.
|
|
84
|
+
*/
|
|
85
|
+
export function GlobalNotes() {
|
|
86
|
+
const editor = useEditor();
|
|
87
|
+
const state = useStore(editor.keep, (k) => k.state);
|
|
88
|
+
const error = useStore(editor.keep, (k) => k.error);
|
|
89
|
+
if (state === 'refused') {
|
|
90
|
+
return (
|
|
91
|
+
<div className="note danger" role="alert" data-note="refused">
|
|
92
|
+
<Icon name="warn" />
|
|
93
|
+
<div>
|
|
94
|
+
<b>The server didn't keep your latest changes</b>
|
|
95
|
+
<p>It said: {error ?? 'the write was refused.'}</p>
|
|
96
|
+
<p>Held in this browser.</p>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (state !== 'offline') return null;
|
|
102
|
+
if (editor.mode === 'live') return <LiveOffline />;
|
|
103
|
+
return (
|
|
104
|
+
<div className="note danger" role="alert" data-note="offline">
|
|
105
|
+
<Icon name="offline" />
|
|
106
|
+
<div>
|
|
107
|
+
<b>Can't reach the server</b>
|
|
108
|
+
<p>Changes are only in this tab. Keep it open.</p>
|
|
109
|
+
{/* M4: "Trying again in 12 s." and the unsent count. */}
|
|
110
|
+
<p data-slot="retry-countdown" hidden />
|
|
111
|
+
{/* M4: "Try now". */}
|
|
112
|
+
<div className="acts" data-slot="try-now" hidden />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Live mode's offline note (the mockup's #offline): the unsent count, the countdown and Try now. */
|
|
119
|
+
function LiveOffline() {
|
|
120
|
+
const editor = useEditor();
|
|
121
|
+
const n = useStore(editor.keep, (k) => k.unsent ?? 0);
|
|
122
|
+
return (
|
|
123
|
+
<div className="note danger" role="alert" data-note="offline">
|
|
124
|
+
<Icon name="offline" />
|
|
125
|
+
<div>
|
|
126
|
+
<b>Can't reach the server</b>
|
|
127
|
+
<p data-unsent={n}>
|
|
128
|
+
{n > 0
|
|
129
|
+
? `${n} change${n === 1 ? '' : 's'} only in this tab. Keep it open.`
|
|
130
|
+
: 'Keep this tab open.'}
|
|
131
|
+
</p>
|
|
132
|
+
<OfflineExtras />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The Recent strip at the foot of the pane (per browser, from the cookie: Decision 20). */
|
|
139
|
+
export function RecentStrip() {
|
|
140
|
+
const editor = useEditor();
|
|
141
|
+
const recent = useStore(editor.ui, (u) => u.recent);
|
|
142
|
+
const documents = useStore(editor.documents);
|
|
143
|
+
const current = useStore(editor.doc.state, (s) => docId(s.ref));
|
|
144
|
+
const items = recent
|
|
145
|
+
.map((id) => documents.find((d) => docId(d) === id))
|
|
146
|
+
.filter((d): d is NonNullable<typeof d> => !!d && !d.warning)
|
|
147
|
+
.slice(0, 5);
|
|
148
|
+
return (
|
|
149
|
+
<div className="side-foot">
|
|
150
|
+
<span className="lbl">Recent</span>
|
|
151
|
+
<div className="rchips">
|
|
152
|
+
{items.map((d) => (
|
|
153
|
+
<button
|
|
154
|
+
key={docId(d)}
|
|
155
|
+
type="button"
|
|
156
|
+
className={`rchip${docId(d) === current ? ' is-current' : ''}`}
|
|
157
|
+
onClick={() => void editor.openDocument({ collection: d.collection, path: d.path })}
|
|
158
|
+
>
|
|
159
|
+
{d.label}
|
|
160
|
+
{docId(d) === current ? <RecentMark /> : null}
|
|
161
|
+
{editor.mode === 'live' ? <DocMarksView file={d.file} compact /> : null}
|
|
162
|
+
</button>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function RecentMark() {
|
|
170
|
+
return useTooLong(true) ? (
|
|
171
|
+
<span className="wmark" title="Too long for mobile">
|
|
172
|
+
<Icon name="phone" size="xs" />
|
|
173
|
+
<span className="sr">, too long for mobile</span>
|
|
174
|
+
</span>
|
|
175
|
+
) : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A document's parent label for crumbs (`Services`): see nav/search.ts. */
|
|
179
|
+
export { parentLabel } from '../nav/search.ts';
|