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,995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor: its state containers and the actions the pane, the bar and the stage
|
|
3
|
+
* call. Built the same way on the server (to render the chrome, Decision 2) and in
|
|
4
|
+
* the browser (to hydrate it): `createEditor(props, { client })`. Only the browser
|
|
5
|
+
* gets the keeper and the preview driver, and only `start()` touches the window.
|
|
6
|
+
*
|
|
7
|
+
* ## The extension points (Phases 3 and 4 build on these; see editor/README.md)
|
|
8
|
+
*
|
|
9
|
+
* editor.doc the open document's editing state and its operations
|
|
10
|
+
* (state/document-store.ts)
|
|
11
|
+
* editor.view where the pane is: { level, collection, block, field, picked, width, pop }
|
|
12
|
+
* editor.openDocument(ref) open a document inside the editor (address, Recent, old preview held)
|
|
13
|
+
* editor.openBlock(key, field?, { picked }) the block level, optionally with a field picked
|
|
14
|
+
* editor.openCollection(name) / goTo(level) / goUp()
|
|
15
|
+
* editor.preview the preview driver (preview/driver.ts), browser only
|
|
16
|
+
* editor.previewStatus { painted, failed, showing, opening, warnings }
|
|
17
|
+
* editor.documents the document list; refreshDocuments() re-reads it
|
|
18
|
+
* editor.ui per-browser sidebar state in a cookie (ui-state.ts): toggleGroup(), setSideHidden()
|
|
19
|
+
* editor.keep the bar's keep state (state/keeper.ts)
|
|
20
|
+
* editor.announce(text) a polite screen-reader announcement
|
|
21
|
+
*
|
|
22
|
+
* ## Live mode (M4 Phase 4)
|
|
23
|
+
*
|
|
24
|
+
* editor.mode 'local' (M3: no polling, none of M4's places) or 'live'
|
|
25
|
+
* editor.liveSession the poll and the server calls (state/live.ts), null in local mode
|
|
26
|
+
* editor.liveState the last status, whether the server answers, signed out
|
|
27
|
+
* editor.access the open document as this tab may use it: its lock, held
|
|
28
|
+
* edits, signed out; `readOnly` and `veil` (Decisions 9 and 11)
|
|
29
|
+
* editor.marks presence, locks, waiting and notices by file, for navigation
|
|
30
|
+
* editor.editHere() / useMine() / discardMine() / publish() / tryAgain() /
|
|
31
|
+
* dismissNotice(id) / signOut() / signInAgain()
|
|
32
|
+
*/
|
|
33
|
+
import { addressOf, inboxAddress, inboxFromAddress, refFromAddress } from '../addresses.ts';
|
|
34
|
+
import {
|
|
35
|
+
docId,
|
|
36
|
+
ENDPOINTS,
|
|
37
|
+
sameDoc,
|
|
38
|
+
UNREAD_HEADER,
|
|
39
|
+
type AdminProps,
|
|
40
|
+
type DocRef,
|
|
41
|
+
type EditorForm,
|
|
42
|
+
type EditorBrand,
|
|
43
|
+
type EditorSite,
|
|
44
|
+
type ListedDocument,
|
|
45
|
+
type LockState,
|
|
46
|
+
type LockView,
|
|
47
|
+
type AnyOpenedDocument as OpenedDocument,
|
|
48
|
+
type Person,
|
|
49
|
+
type SignedOutError,
|
|
50
|
+
type MediaSetup,
|
|
51
|
+
type SubmissionsSetup,
|
|
52
|
+
type UiState,
|
|
53
|
+
type ViewMode,
|
|
54
|
+
type Width,
|
|
55
|
+
} from '../types.ts';
|
|
56
|
+
import type { MediaOnly } from '../../media-value.ts';
|
|
57
|
+
import { displayName } from '../../live/messages.ts';
|
|
58
|
+
import { uiCookieString, unsentCookieString, withOpenAncestors, withRecent, withSide, withToggled, withViewMode } from '../ui-state.ts';
|
|
59
|
+
import type { StageLayout } from './preview/layout.ts';
|
|
60
|
+
import { createPreviewDriver, type PreviewDriver, type PreviewStatus } from './preview/driver.ts';
|
|
61
|
+
import { createDocumentStore, type DocumentStore } from './state/document-store.ts';
|
|
62
|
+
import { createKeeper, type HeldView, type Keeper, type KeepStatus } from './state/keeper.ts';
|
|
63
|
+
import { createLiveSession, tabId, type LiveSession, type LiveState } from './state/live.ts';
|
|
64
|
+
import { createStore, type Store } from './state/store.ts';
|
|
65
|
+
|
|
66
|
+
export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* M5b: Choose… (or Replace…, or a list's Add image) opened the library as a picker for
|
|
70
|
+
* one slot (the mockup's #library-pick): `scope` and `path` are the host's (the block's
|
|
71
|
+
* key, or a form's, and a dotted path: `media`, `images.6`, `cover`), so placing is
|
|
72
|
+
* `doc.setField(scope, path, value)`. The page stays on the stage with the slot ringed.
|
|
73
|
+
*/
|
|
74
|
+
export interface MediaPick {
|
|
75
|
+
scope: string;
|
|
76
|
+
path: string;
|
|
77
|
+
only?: MediaOnly;
|
|
78
|
+
/** A loop-only slot (the hero): a clip placed there stores `"play": "loop"`. */
|
|
79
|
+
play?: 'loop';
|
|
80
|
+
/** The picker's sub line: "Text and media · Image or video", "Gallery · Images, image 7". */
|
|
81
|
+
label: string;
|
|
82
|
+
/** Where Choose… was pressed (the level tab above the picker, and Back): "Text and media". */
|
|
83
|
+
owner: string;
|
|
84
|
+
/** A list's Add image: each pick adds a row (at `path`'s index, then the next), and the picker stays open. */
|
|
85
|
+
multi: boolean;
|
|
86
|
+
/** The level the picker goes back to. */
|
|
87
|
+
back: 'block' | 'document';
|
|
88
|
+
/**
|
|
89
|
+
* M6c G4 (Decision 7): File… in a link field or a button: the library opens on Files
|
|
90
|
+
* only, and picking writes the file's address to `path` (the link's `href`, as a string).
|
|
91
|
+
*/
|
|
92
|
+
link?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** M5b: the library level (Site-wide → Media, or a slot's picker). */
|
|
96
|
+
export interface MediaView {
|
|
97
|
+
pick: MediaPick | null;
|
|
98
|
+
/** An image's (its key) or a clip's (`clip:<asset id>`) own level, from the library. */
|
|
99
|
+
item: string | null;
|
|
100
|
+
/** M6c G4: `files`, the library's files. */
|
|
101
|
+
tab: 'images' | 'clips' | 'files';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface View {
|
|
105
|
+
level: PaneLevel;
|
|
106
|
+
/** The collection level's collection. */
|
|
107
|
+
collection: string | null;
|
|
108
|
+
/** The block level's block (its key in the document store). */
|
|
109
|
+
block: string | null;
|
|
110
|
+
/** The field picked in the block level (Phase 3), or null. */
|
|
111
|
+
field: string | null;
|
|
112
|
+
/** The field was picked by clicking the page ("Picked from the page"). */
|
|
113
|
+
picked: boolean;
|
|
114
|
+
/** The preview width on screen. All three stay rendered (Decision 13). */
|
|
115
|
+
width: Width;
|
|
116
|
+
/** An open popover over the bar: 'switcher' (Phase 4), 'preview-trouble', or a later phase's. */
|
|
117
|
+
pop: string | null;
|
|
118
|
+
/** A document being read before it opens: the bar says "Opening …". */
|
|
119
|
+
opening: DocRef | null;
|
|
120
|
+
/** M5b: the library level's state (level `media`); null otherwise. Optional so older callers' views still type. */
|
|
121
|
+
media?: MediaView | null;
|
|
122
|
+
/** M6: the inbox (level `submissions`): the submission open on its sheet, or null for the list alone. */
|
|
123
|
+
inbox?: { id: string | null } | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The open document as this tab may use it (M4 Decisions 9 and 11). Local mode: always editable. */
|
|
127
|
+
export interface Access {
|
|
128
|
+
/** Its lock, from this tab's side: `free`, `yours`, `yours-elsewhere` (Edit here) or `theirs`. */
|
|
129
|
+
lock: LockState;
|
|
130
|
+
/** Who holds it (null when free). */
|
|
131
|
+
holder: LockView | null;
|
|
132
|
+
/** This document's held edits (a 409, or another editor's change since: Use mine / Discard mine). */
|
|
133
|
+
held: HeldView | null;
|
|
134
|
+
/** The session ended (or the person was removed): edits stay in the tab. */
|
|
135
|
+
signedOut: SignedOutError | null;
|
|
136
|
+
/** No typing, no overlays, disabled fields. */
|
|
137
|
+
readOnly: boolean;
|
|
138
|
+
/** The lock's veil over the preview: only a lock gives it (Decision 9). */
|
|
139
|
+
veil: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* M5a Decision 12: someone deleted the open document (the poll's `open.deleted`): who
|
|
142
|
+
* and when, when that's known. Read-only, with a note saying so. Null while it exists.
|
|
143
|
+
*/
|
|
144
|
+
deleted: { by: Person | null; at: string | null } | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What navigation shows for a document (by file), live mode only. */
|
|
148
|
+
export interface DocMarks {
|
|
149
|
+
/** Someone else's lock: "Aoife is editing". */
|
|
150
|
+
editing: Person | null;
|
|
151
|
+
/** Other people with it open (presence, Decision 9), the lock's holder not repeated. */
|
|
152
|
+
present: Person[];
|
|
153
|
+
/** Changes waiting for the next Publish. */
|
|
154
|
+
waiting: boolean;
|
|
155
|
+
/** The merge kept the draft (Decision 7's notice, until it's dismissed). */
|
|
156
|
+
kept: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Live mode's retry timer while offline (keeper.ts): it backs off, and the last repeats. */
|
|
160
|
+
export const RETRY_DELAYS = [3000, 6000, 12000, 24000, 30000] as const;
|
|
161
|
+
|
|
162
|
+
/** A message for the toast (not an Undo): a lock lapsing. `id` rises, so the same text shows again. */
|
|
163
|
+
export interface ToastMessage {
|
|
164
|
+
id: number;
|
|
165
|
+
text: string;
|
|
166
|
+
/** An icon from the sprite (`check`), or none. */
|
|
167
|
+
icon: string | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The lapse toast (the mockup's #lapsed, now just "About is free to edit."):
|
|
172
|
+
* when someone else's lock on the open document goes and the document opens up. Not
|
|
173
|
+
* for Edit here (your own lock), nor for opening another document.
|
|
174
|
+
*/
|
|
175
|
+
export function lapsedToast(previous: Access, next: Access, openFile: string, title: string): string | null {
|
|
176
|
+
if (previous.lock !== 'theirs' || !previous.holder || previous.holder.file !== openFile) return null;
|
|
177
|
+
if (next.lock !== 'free' || next.veil || next.signedOut) return null;
|
|
178
|
+
return `${title} is free to edit.`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Take over's toast, for the tab that lost the lock: someone else now holds the open
|
|
183
|
+
* document's lock that this tab held. Its changes so far were committed under its name.
|
|
184
|
+
*/
|
|
185
|
+
export function takenOverToast(previous: Access, next: Access, openFile: string, title: string): string | null {
|
|
186
|
+
if (previous.lock !== 'yours' || next.lock !== 'theirs' || !next.holder || next.holder.file !== openFile) return null;
|
|
187
|
+
return `${displayName(next.holder.person)} took over ${title}.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const EDITABLE: Access = { lock: 'free', holder: null, held: null, signedOut: null, readOnly: false, veil: false, deleted: null };
|
|
191
|
+
const NO_LIVE: LiveState = { status: null, reachable: true, signedOut: null, polled: false, seq: 0 };
|
|
192
|
+
|
|
193
|
+
export interface Editor {
|
|
194
|
+
site: EditorSite;
|
|
195
|
+
/** M4: `local` (M3's editor, on disk) or `live` (sign-in, locks, Publish). */
|
|
196
|
+
mode: 'local' | 'live';
|
|
197
|
+
/** Who is signed in (live mode), else null. */
|
|
198
|
+
you: Person | null;
|
|
199
|
+
liveSession: LiveSession | null;
|
|
200
|
+
/** Live mode's last status; constant and empty in local mode. */
|
|
201
|
+
liveState: Store<LiveState>;
|
|
202
|
+
access: Store<Access>;
|
|
203
|
+
/** By file. Empty in local mode. */
|
|
204
|
+
marks: Store<ReadonlyMap<string, DocMarks>>;
|
|
205
|
+
documents: Store<ListedDocument[]>;
|
|
206
|
+
doc: DocumentStore;
|
|
207
|
+
view: Store<View>;
|
|
208
|
+
ui: Store<UiState>;
|
|
209
|
+
keep: Store<KeepStatus>;
|
|
210
|
+
previewStatus: Store<PreviewStatus>;
|
|
211
|
+
/** Browser only (null while rendering on the server). */
|
|
212
|
+
preview: PreviewDriver | null;
|
|
213
|
+
keeper: Keeper | null;
|
|
214
|
+
/** The latest polite announcement (the #live region). */
|
|
215
|
+
live: Store<string>;
|
|
216
|
+
/** The latest message toast (shell/Toast.tsx), e.g. a lock lapsing; null until there's one. */
|
|
217
|
+
message: Store<ToastMessage | null>;
|
|
218
|
+
/** Where the Stage put each frame (layout.ts), once it has measured itself; null on the server. */
|
|
219
|
+
stageLayout: Store<StageLayout | null>;
|
|
220
|
+
/**
|
|
221
|
+
* The brand as it applies now (M5a Decision 10): Site Settings' name and accent as
|
|
222
|
+
* committed. Starts as the page's (`site.brand`); in live mode the status poll
|
|
223
|
+
* replaces it, so a change shows within a minute with no reload (the accent's CSS
|
|
224
|
+
* variables and the tab title follow it).
|
|
225
|
+
*/
|
|
226
|
+
brand: Store<EditorBrand>;
|
|
227
|
+
/**
|
|
228
|
+
* M5b: whether the media routes are set up (Decision 5), from /admin's props; a route
|
|
229
|
+
* that later answers 503 updates it (client/media/store.ts).
|
|
230
|
+
*/
|
|
231
|
+
mediaSetup: Store<MediaSetup>;
|
|
232
|
+
/** M5b: the library level: to browse (no `pick`) or as a slot's picker. */
|
|
233
|
+
openMedia(options?: { pick?: MediaPick | null; item?: string | null; tab?: MediaView['tab'] }): void;
|
|
234
|
+
/** M6: the site's forms (id, label, fields in order), for Site Settings' Form emails and the Form block's picker. */
|
|
235
|
+
forms: EditorForm[];
|
|
236
|
+
/**
|
|
237
|
+
* M6 Decision 9: the inbox's unread count for the Site-wide row, or why there's none
|
|
238
|
+
* (`submissions-off`). From /admin's props, then every submissions response and the
|
|
239
|
+
* documents list's `X-Caracms-Unread` (client/inbox/store.ts sets it too).
|
|
240
|
+
*/
|
|
241
|
+
submissions: Store<SubmissionsSetup>;
|
|
242
|
+
/** M6: the inbox's first page and open submission as /admin rendered them (client/inbox/store.ts starts from it). */
|
|
243
|
+
inboxFirst: AdminProps['inboxFirst'];
|
|
244
|
+
/**
|
|
245
|
+
* M6: the inbox level (Site-wide → Submissions), with a submission open on its sheet
|
|
246
|
+
* when `id` is given. The address follows (`/admin/submissions[/<id>]`): pushed when
|
|
247
|
+
* coming from elsewhere, replaced within the inbox.
|
|
248
|
+
*/
|
|
249
|
+
openInbox(id?: string | null, options?: { history?: 'push' | 'replace' | 'none' }): void;
|
|
250
|
+
|
|
251
|
+
openDocument(ref: DocRef, options?: { history?: 'push' | 'replace' | 'none' }): Promise<void>;
|
|
252
|
+
openBlock(key: string, field?: string | null, options?: { picked?: boolean }): void;
|
|
253
|
+
openCollection(name: string): void;
|
|
254
|
+
goTo(level: PaneLevel): void;
|
|
255
|
+
/** Esc: block → document → documents (a collection → documents; a case study or shared block → its collection). False when already at the top. */
|
|
256
|
+
goUp(): boolean;
|
|
257
|
+
/**
|
|
258
|
+
* Whether fields are open for picking at this view: the block level, or (M5a) the
|
|
259
|
+
* document level of a form (a fields document, a shared block, Site Settings), where
|
|
260
|
+
* `view.block` is the form's one block and `view.field` the picked field.
|
|
261
|
+
*/
|
|
262
|
+
fieldsOpen(view?: View): boolean;
|
|
263
|
+
setWidth(width: Width): void;
|
|
264
|
+
setPop(pop: string | null): void;
|
|
265
|
+
toggleGroup(key: string, open?: boolean): void;
|
|
266
|
+
/** Hide or show the sidebar (pre-M7 polish), remembered in the ui cookie; the preview takes the width. */
|
|
267
|
+
setSideHidden(hidden: boolean): void;
|
|
268
|
+
/** M6c (G5, Decision 18): show a flat collection as a list or a grid, remembered in the UI cookie. */
|
|
269
|
+
setViewMode(collection: string, mode: ViewMode): void;
|
|
270
|
+
refreshDocuments(): Promise<void>;
|
|
271
|
+
/** M6c G4: read the open document again, as the server has it now (Replace… rewrote its links). Not while this tab has changes the server doesn't. */
|
|
272
|
+
reloadOpen(): Promise<void>;
|
|
273
|
+
announce(text: string): void;
|
|
274
|
+
/** Show a message in the toast for a few seconds (the mockup's `toast(text, false, icon)`). */
|
|
275
|
+
toast(text: string, icon?: string): void;
|
|
276
|
+
/** The address of a document in /admin (Decision 19). */
|
|
277
|
+
addressOf(ref: DocRef): string;
|
|
278
|
+
/** Browser only: wire up history, keeping, recovery and pagehide. Returns a stop. */
|
|
279
|
+
start(): () => void;
|
|
280
|
+
|
|
281
|
+
// Live mode (M4). No-ops in local mode.
|
|
282
|
+
/** Edit here: move your own lock from another tab to this one (Decision 9). */
|
|
283
|
+
editHere(): Promise<void>;
|
|
284
|
+
/** Take over: move someone else's lock to this tab; their changes so far are committed under their name. */
|
|
285
|
+
takeOver(): Promise<void>;
|
|
286
|
+
/** Use mine: send this document's held edits, merged (Decision 11). Off while someone else holds the lock. */
|
|
287
|
+
useMine(): Promise<void>;
|
|
288
|
+
/** Discard mine: drop them and show the document as the server has it. */
|
|
289
|
+
discardMine(): Promise<void>;
|
|
290
|
+
/** Publish. `ok: false` with the reason when it didn't publish (the Publish popover says so; press again to retry). */
|
|
291
|
+
publish(): Promise<{ ok: boolean; error?: string }>;
|
|
292
|
+
tryAgain(): Promise<{ ok: boolean; error?: string }>;
|
|
293
|
+
dismissNotice(id: string): void;
|
|
294
|
+
signOut(): Promise<void>;
|
|
295
|
+
/** Signed out: open sign-in in a new tab; this tab sends its edits once the session is back. */
|
|
296
|
+
signInAgain(): void;
|
|
297
|
+
/** The site's time zone (Decision 16), from the status. */
|
|
298
|
+
timeZone(): string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function createEditor(props: AdminProps, options: { client: boolean }): Editor {
|
|
302
|
+
const { site } = props;
|
|
303
|
+
const opened = props.opened;
|
|
304
|
+
const liveProps = props.live ?? null;
|
|
305
|
+
// Read-only (someone else's lock, held edits, signed out) makes the store refuse changes too.
|
|
306
|
+
let readOnly = () => false;
|
|
307
|
+
const doc = createDocumentStore(
|
|
308
|
+
site,
|
|
309
|
+
{
|
|
310
|
+
ref: { collection: opened.entry.collection, path: opened.entry.path },
|
|
311
|
+
entry: opened.entry,
|
|
312
|
+
base: opened.text,
|
|
313
|
+
document: opened.document,
|
|
314
|
+
},
|
|
315
|
+
{ canEdit: () => !readOnly() },
|
|
316
|
+
);
|
|
317
|
+
const documents = createStore(props.documents);
|
|
318
|
+
const view = createStore<View>({ level: 'document', collection: null, block: null, field: null, picked: false, width: 'desktop', pop: null, opening: null });
|
|
319
|
+
const ui = createStore<UiState>(props.ui);
|
|
320
|
+
const live = createStore('');
|
|
321
|
+
const message = createStore<ToastMessage | null>(null);
|
|
322
|
+
const stageLayout = createStore<StageLayout | null>(null);
|
|
323
|
+
const brand = createStore<EditorBrand>(site.brand);
|
|
324
|
+
const mediaSetup = createStore<MediaSetup>(props.media ?? { images: null, clips: null });
|
|
325
|
+
const submissions = createStore<SubmissionsSetup>(props.submissions ?? { unread: 0 });
|
|
326
|
+
// M6: /admin/submissions[/<id>] opens on the inbox, with Home under it.
|
|
327
|
+
if (props.inbox) view.update({ level: 'submissions', inbox: { id: props.inbox.id } });
|
|
328
|
+
|
|
329
|
+
// Live mode (M4): the poll and the server calls; a tab id that lasts the tab's life.
|
|
330
|
+
const tab = liveProps ? (options.client ? tabId() : 'server') : null;
|
|
331
|
+
const liveSession = liveProps
|
|
332
|
+
? createLiveSession({ fetch: (...a) => fetch(...a), props: liveProps, tab: tab!, openFile: () => doc.get().entry.file })
|
|
333
|
+
: null;
|
|
334
|
+
const liveState = liveSession?.state ?? createStore<LiveState>(NO_LIVE);
|
|
335
|
+
const you = () => liveState.get().status?.you ?? liveProps?.you ?? null;
|
|
336
|
+
/** A lock answer (a 409, or Edit here) shown at once, until a poll started after it answers. */
|
|
337
|
+
const lockOverride = createStore<{ file: string; state: LockState; lock: LockView | null; after: number } | null>(null);
|
|
338
|
+
|
|
339
|
+
// Unsent edits held in this browser (the cookie says so): the bar starts at Keeping.
|
|
340
|
+
const initialKeep = props.unsent ? 'keeping' : 'kept';
|
|
341
|
+
const keeper = options.client
|
|
342
|
+
? createKeeper({
|
|
343
|
+
fetch: (...a) => fetch(...a),
|
|
344
|
+
site,
|
|
345
|
+
storage: safeStorage(),
|
|
346
|
+
storageKey: site.storageKey,
|
|
347
|
+
initial: initialKeep,
|
|
348
|
+
onHeld: (held) => (document.cookie = unsentCookieString(site.storageKey, held)),
|
|
349
|
+
...(liveSession
|
|
350
|
+
? {
|
|
351
|
+
tab,
|
|
352
|
+
retryDelays: RETRY_DELAYS,
|
|
353
|
+
you,
|
|
354
|
+
lockedFiles: () => {
|
|
355
|
+
const files = new Set((liveState.get().status?.locks ?? []).filter((l) => l.mine === 'this-tab').map((l) => l.file));
|
|
356
|
+
const o = lockOverride.get();
|
|
357
|
+
if (o?.state === 'yours') files.add(o.file);
|
|
358
|
+
return [...files];
|
|
359
|
+
},
|
|
360
|
+
onLocked: (file, refusal) => lockOverride.set({ file, state: refusal.state, lock: refusal.lock, after: liveSession.started() }),
|
|
361
|
+
onSignedOut: (body) => liveState.update({ signedOut: body }),
|
|
362
|
+
}
|
|
363
|
+
: {}),
|
|
364
|
+
})
|
|
365
|
+
: null;
|
|
366
|
+
const keep = keeper?.status ?? createStore<KeepStatus>({ state: initialKeep, error: null });
|
|
367
|
+
const preview = options.client
|
|
368
|
+
? createPreviewDriver({
|
|
369
|
+
store: doc,
|
|
370
|
+
site,
|
|
371
|
+
home: site.home,
|
|
372
|
+
// Live mode: a 401 is the signed-out state, not "Preview not updated". The keeper's
|
|
373
|
+
// pending writes go first, so the signed-out note counts what didn't reach the server.
|
|
374
|
+
...(liveSession
|
|
375
|
+
? {
|
|
376
|
+
onActivity: () => liveSession.active(),
|
|
377
|
+
onSignedOut: (body: unknown) =>
|
|
378
|
+
void (keeper?.flush() ?? Promise.resolve()).finally(() => {
|
|
379
|
+
if (!liveState.get().signedOut) liveState.update({ signedOut: signedOutFrom(body) });
|
|
380
|
+
}),
|
|
381
|
+
}
|
|
382
|
+
: {}),
|
|
383
|
+
})
|
|
384
|
+
: null;
|
|
385
|
+
const previewStatus =
|
|
386
|
+
preview?.status ?? createStore<PreviewStatus>({ painted: false, failed: false, showing: doc.get().document.kind === 'settings' ? site.home : doc.get().ref, opening: null, warnings: [] });
|
|
387
|
+
|
|
388
|
+
// ── Access: the open document's lock, held edits, the session (live mode) ────
|
|
389
|
+
const sameAccess = (a: Access, b: Access) =>
|
|
390
|
+
a.lock === b.lock &&
|
|
391
|
+
a.readOnly === b.readOnly &&
|
|
392
|
+
a.veil === b.veil &&
|
|
393
|
+
a.signedOut === b.signedOut &&
|
|
394
|
+
a.held === b.held &&
|
|
395
|
+
JSON.stringify(a.holder) === JSON.stringify(b.holder);
|
|
396
|
+
function computeAccess(): Access {
|
|
397
|
+
if (!liveSession) return EDITABLE;
|
|
398
|
+
const s = liveState.get();
|
|
399
|
+
const file = doc.get().entry.file;
|
|
400
|
+
let lock: LockState = 'free';
|
|
401
|
+
let holder: LockView | null = null;
|
|
402
|
+
const o = lockOverride.get();
|
|
403
|
+
if (o && o.file === file && s.seq <= o.after) {
|
|
404
|
+
lock = o.state;
|
|
405
|
+
holder = o.lock;
|
|
406
|
+
} else {
|
|
407
|
+
const l = s.status?.locks.find((x) => x.file === file) ?? null;
|
|
408
|
+
if (l) {
|
|
409
|
+
holder = l;
|
|
410
|
+
// The page's first status had no tab: a lock of yours may be this very tab's (a reload), so wait for the first poll.
|
|
411
|
+
lock = l.mine === 'this-tab' ? 'yours' : l.mine === 'other-tab' ? (s.polled ? 'yours-elsewhere' : 'free') : 'theirs';
|
|
412
|
+
if (lock === 'free') holder = null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const id = docId(doc.get().ref);
|
|
416
|
+
const held = keeper?.status.get().held?.find((h) => h.id === id) ?? null;
|
|
417
|
+
const veil = lock === 'theirs' || lock === 'yours-elsewhere';
|
|
418
|
+
const open = s.status?.open;
|
|
419
|
+
const deleted = open && open.file === file && open.deleted ? open.deleted : null;
|
|
420
|
+
return { lock, holder, held, signedOut: s.signedOut, readOnly: veil || !!held || !!s.signedOut || !!deleted, veil, deleted };
|
|
421
|
+
}
|
|
422
|
+
const access = createStore<Access>(computeAccess());
|
|
423
|
+
readOnly = () => access.get().readOnly;
|
|
424
|
+
const recomputeAccess = () => {
|
|
425
|
+
const next = computeAccess();
|
|
426
|
+
if (!sameAccess(next, access.get())) access.set(next);
|
|
427
|
+
};
|
|
428
|
+
if (liveSession) {
|
|
429
|
+
liveState.subscribe(recomputeAccess);
|
|
430
|
+
lockOverride.subscribe(recomputeAccess);
|
|
431
|
+
keeper?.status.subscribe(recomputeAccess);
|
|
432
|
+
doc.state.subscribe((s, previous) => s.ref !== previous.ref && recomputeAccess());
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Marks for navigation: presence, locks, waiting, the merge notice (by file) ──
|
|
436
|
+
function computeMarks(): ReadonlyMap<string, DocMarks> {
|
|
437
|
+
const out = new Map<string, DocMarks>();
|
|
438
|
+
const status = liveState.get().status;
|
|
439
|
+
if (!status) return out;
|
|
440
|
+
const me = (p: Person) => p.email.toLowerCase() === status.you.email.toLowerCase();
|
|
441
|
+
const of = (file: string) => {
|
|
442
|
+
let m = out.get(file);
|
|
443
|
+
if (!m) out.set(file, (m = { editing: null, present: [], waiting: false, kept: false }));
|
|
444
|
+
return m;
|
|
445
|
+
};
|
|
446
|
+
for (const l of status.locks) if (l.mine === null && !me(l.person)) of(l.file).editing = l.person;
|
|
447
|
+
for (const p of status.presence) {
|
|
448
|
+
const m = of(p.file);
|
|
449
|
+
m.present = p.people.filter((x) => !me(x) && (!m.editing || x.email.toLowerCase() !== m.editing.email.toLowerCase()));
|
|
450
|
+
}
|
|
451
|
+
for (const w of status.waiting) of(w.file).waiting = true;
|
|
452
|
+
for (const n of status.notices) if (n.kind === 'draft-kept') of(n.file).kept = true;
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
const marks = createStore<ReadonlyMap<string, DocMarks>>(computeMarks());
|
|
456
|
+
if (liveSession) liveState.subscribe((s, previous) => s.status !== previous.status && marks.set(computeMarks()));
|
|
457
|
+
|
|
458
|
+
// Every change to the document is kept (keeper.ts); opening one isn't a change.
|
|
459
|
+
doc.subscribe((session, change) => {
|
|
460
|
+
if (change.kind === 'load' || !keeper) return;
|
|
461
|
+
keeper.change(docId(session.ref), { file: session.entry.file, base: session.base, document: doc.wire() });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
/** The latest openDocument call wins. */
|
|
465
|
+
let opening = 0;
|
|
466
|
+
|
|
467
|
+
async function readDocument(ref: DocRef): Promise<OpenedDocument> {
|
|
468
|
+
const url = `${ENDPOINTS.document}?collection=${encodeURIComponent(ref.collection)}&path=${encodeURIComponent(ref.path)}`;
|
|
469
|
+
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
470
|
+
const body = await response.json();
|
|
471
|
+
if (response.status === 401 && liveSession) liveState.update({ signedOut: body as SignedOutError });
|
|
472
|
+
if (!response.ok) throw new Error(body?.error ?? `couldn't open ${docId(ref)}`);
|
|
473
|
+
return body as OpenedDocument;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The version of the open document as last read, and the last change the poll reported for it: someone else's change while it's locked. */
|
|
477
|
+
let loadedVersion = opened.version;
|
|
478
|
+
let seenChange: string | null = opened.lastChange?.version ?? null;
|
|
479
|
+
|
|
480
|
+
/** Load an opened document into the store, adopting unsent edits that are newer than its file. */
|
|
481
|
+
/**
|
|
482
|
+
* A document whose fields are edited in the sidebar (Site Settings, a case study, a shared
|
|
483
|
+
* block) is no use with the sidebar hidden, so opening one brings it back. A section-builder
|
|
484
|
+
* page keeps it hidden: the preview is the point there.
|
|
485
|
+
*/
|
|
486
|
+
function bringSideBack() {
|
|
487
|
+
if (doc.get().document.kind !== 'sections') editor.setSideHidden(false);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function load(openedDoc: OpenedDocument) {
|
|
491
|
+
const ref = { collection: openedDoc.entry.collection, path: openedDoc.entry.path };
|
|
492
|
+
const id = docId(ref);
|
|
493
|
+
loadedVersion = openedDoc.version;
|
|
494
|
+
seenChange = openedDoc.lastChange?.version ?? null;
|
|
495
|
+
if (keeper && adoptHeld(id, ref, openedDoc)) return;
|
|
496
|
+
doc.load({ ref, entry: openedDoc.entry, base: openedDoc.text, document: openedDoc.document });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Unsent edits held for a document being opened (keeper.ts → the re-send rule): sent
|
|
501
|
+
* when the server's copy doesn't have them and nobody else changed it since; held
|
|
502
|
+
* for Use mine / Discard mine when someone did (Decision 11). True when adopted.
|
|
503
|
+
*/
|
|
504
|
+
function adoptHeld(id: string, ref: DocRef, openedDoc: OpenedDocument): boolean {
|
|
505
|
+
if (!keeper) return false;
|
|
506
|
+
keeper.opened(id, openedDoc.entry.file, openedDoc.version);
|
|
507
|
+
const found = keeper.recover(id, openedDoc);
|
|
508
|
+
if (!found) return false;
|
|
509
|
+
doc.load({ ref, entry: openedDoc.entry, base: found.stored.base, document: found.stored.document }, { adopted: true });
|
|
510
|
+
if (found.send) keeper.resend(id, found.stored);
|
|
511
|
+
else keeper.hold(id, found.stored, found.by);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Read the open document again and show it as the server has it now (a lock lapsed,
|
|
517
|
+
* Edit here, Discard mine), keeping the block that was open. Not while this tab has
|
|
518
|
+
* changes the server doesn't.
|
|
519
|
+
*/
|
|
520
|
+
async function refreshOpen(): Promise<void> {
|
|
521
|
+
const ref = doc.get().ref;
|
|
522
|
+
const id = docId(ref);
|
|
523
|
+
let fresh: OpenedDocument;
|
|
524
|
+
try {
|
|
525
|
+
fresh = await readDocument(ref);
|
|
526
|
+
} catch {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (!sameDoc(doc.get().ref, ref) || keeper?.dirty(id)) return;
|
|
530
|
+
const before = view.get();
|
|
531
|
+
const index = before.block ? doc.indexOf(before.block) : -1;
|
|
532
|
+
load(fresh);
|
|
533
|
+
if (before.level === 'block' && index >= 0) {
|
|
534
|
+
const key = doc.keyAt(index);
|
|
535
|
+
if (key) view.update({ block: key });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Held edits looked at again against the document as it is now (Decision 11). */
|
|
540
|
+
async function recheck(id: string): Promise<void> {
|
|
541
|
+
if (!keeper) return;
|
|
542
|
+
const slash = id.indexOf('/');
|
|
543
|
+
let fresh: OpenedDocument;
|
|
544
|
+
try {
|
|
545
|
+
fresh = await readDocument({ collection: id.slice(0, slash), path: id.slice(slash + 1) });
|
|
546
|
+
} catch {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const result = keeper.recheck(id, fresh);
|
|
550
|
+
if (result === 'drop' && id === docId(doc.get().ref)) await refreshOpen();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function setTitle() {
|
|
554
|
+
if (typeof document === 'undefined') return;
|
|
555
|
+
const what = view.get().level === 'submissions' ? 'Submissions' : doc.get().document.title || doc.get().entry.label;
|
|
556
|
+
document.title = `${what} · ${brand.get().name}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const editor: Editor = {
|
|
560
|
+
site,
|
|
561
|
+
mode: liveSession ? 'live' : 'local',
|
|
562
|
+
you: liveProps?.you ?? null,
|
|
563
|
+
liveSession,
|
|
564
|
+
liveState,
|
|
565
|
+
access,
|
|
566
|
+
marks,
|
|
567
|
+
documents,
|
|
568
|
+
doc,
|
|
569
|
+
view,
|
|
570
|
+
ui,
|
|
571
|
+
keep,
|
|
572
|
+
previewStatus,
|
|
573
|
+
preview,
|
|
574
|
+
keeper,
|
|
575
|
+
live,
|
|
576
|
+
message,
|
|
577
|
+
stageLayout,
|
|
578
|
+
brand,
|
|
579
|
+
mediaSetup,
|
|
580
|
+
forms: props.forms ?? [],
|
|
581
|
+
submissions,
|
|
582
|
+
inboxFirst: props.inboxFirst ?? null,
|
|
583
|
+
|
|
584
|
+
openInbox(id = null, { history } = {}) {
|
|
585
|
+
const before = view.get();
|
|
586
|
+
const within = before.level === 'submissions';
|
|
587
|
+
view.update({ level: 'submissions', inbox: { id }, block: null, field: null, picked: false, pop: null, media: null });
|
|
588
|
+
const how = history ?? (within ? 'replace' : 'push');
|
|
589
|
+
if (how !== 'none' && typeof window !== 'undefined') {
|
|
590
|
+
const address = inboxAddress(id);
|
|
591
|
+
if (location.pathname !== address) {
|
|
592
|
+
if (how === 'push') window.history.pushState({ caracms: 'submissions' }, '', address);
|
|
593
|
+
else window.history.replaceState({ caracms: 'submissions' }, '', address);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
setTitle();
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
openMedia({ pick = null, item = null, tab } = {}) {
|
|
600
|
+
const before = view.get();
|
|
601
|
+
const media: MediaView = { pick, item, tab: tab ?? (pick?.link ? 'files' : pick?.only === 'image' ? 'images' : (before.media?.tab ?? 'images')) };
|
|
602
|
+
// A picker keeps the block and the slot picked, so the page rings it (editor-marks.ts).
|
|
603
|
+
if (pick) view.update({ level: 'media', media, block: pick.scope, field: pick.multi ? listOf(pick.path) : pick.path, picked: false, pop: null });
|
|
604
|
+
else view.update({ level: 'media', media, field: null, picked: false, pop: null });
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
async openDocument(ref, { history = 'push' } = {}) {
|
|
608
|
+
const current = doc.get().ref;
|
|
609
|
+
if (sameDoc(ref, current) && !view.get().opening) {
|
|
610
|
+
view.update({ level: 'document', block: null, field: null, picked: false, pop: null, inbox: null });
|
|
611
|
+
// Leaving the inbox for the page under it: the address names the page again.
|
|
612
|
+
if (history !== 'none' && typeof window !== 'undefined' && location.pathname !== addressOf(ref, site.home)) {
|
|
613
|
+
if (history === 'push') window.history.pushState({ caracms: docId(ref) }, '', addressOf(ref, site.home));
|
|
614
|
+
else window.history.replaceState({ caracms: docId(ref) }, '', addressOf(ref, site.home));
|
|
615
|
+
}
|
|
616
|
+
setTitle();
|
|
617
|
+
bringSideBack();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const ticket = ++opening;
|
|
621
|
+
view.update({ opening: ref, pop: null });
|
|
622
|
+
if (keeper) void keeper.flush(docId(current));
|
|
623
|
+
let openedDoc: OpenedDocument;
|
|
624
|
+
try {
|
|
625
|
+
openedDoc = await readDocument(ref);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (ticket === opening) view.update({ opening: null });
|
|
628
|
+
console.warn('[caracms]', error);
|
|
629
|
+
editor.announce(`${docId(ref)} couldn't be opened`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (ticket !== opening) return;
|
|
633
|
+
load(openedDoc);
|
|
634
|
+
view.update({ level: 'document', block: null, field: null, picked: false, opening: null, collection: null, inbox: null });
|
|
635
|
+
// The new document's lock and presence at once, not in up to five seconds.
|
|
636
|
+
if (liveSession && options.client) void liveSession.poll();
|
|
637
|
+
ui.set((state) => withRecent(withOpenAncestors(state, ref), ref));
|
|
638
|
+
if (history !== 'none' && typeof window !== 'undefined') {
|
|
639
|
+
const address = addressOf(ref, site.home);
|
|
640
|
+
if (history === 'push') window.history.pushState({ caracms: docId(ref) }, '', address);
|
|
641
|
+
else window.history.replaceState({ caracms: docId(ref) }, '', address);
|
|
642
|
+
}
|
|
643
|
+
setTitle();
|
|
644
|
+
bringSideBack();
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
openBlock(key, field = null, { picked = false } = {}) {
|
|
648
|
+
if (!doc.block(key)) return;
|
|
649
|
+
// A form (a fields document, a shared block, Site Settings: M5a) is its own document
|
|
650
|
+
// level: its fields are picked there, with the form's one block as `view.block`.
|
|
651
|
+
const level = doc.get().document.kind === 'sections' ? 'block' : 'document';
|
|
652
|
+
view.update({ level, block: key, field, picked, pop: null });
|
|
653
|
+
// A block opened from the preview (a click on it) brings a hidden sidebar back: its fields are there.
|
|
654
|
+
editor.setSideHidden(false);
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
openCollection(name) {
|
|
658
|
+
view.update({ level: 'collection', collection: name, pop: null });
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
goTo(level) {
|
|
662
|
+
if (level === 'block' && !view.get().block) return;
|
|
663
|
+
if (level === 'collection' && !view.get().collection) return;
|
|
664
|
+
view.update({ level, ...(level !== 'block' ? { field: null, picked: false } : {}) });
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
fieldsOpen(v = view.get()) {
|
|
668
|
+
return v.level === 'block' || (v.level === 'document' && doc.get().document.kind !== 'sections');
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
goUp() {
|
|
672
|
+
const { level, media, inbox } = view.get();
|
|
673
|
+
const { kind } = doc.get().document;
|
|
674
|
+
// M6: the inbox: an open submission → the list; the list → documents.
|
|
675
|
+
if (level === 'submissions') {
|
|
676
|
+
if (inbox?.id) editor.openInbox(null);
|
|
677
|
+
else view.update({ level: 'documents', inbox: null });
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
// The library: an item's own level → the library; a picker → where Choose… was pressed; else documents.
|
|
681
|
+
if (level === 'media') {
|
|
682
|
+
if (media?.item) view.update({ media: { ...media, item: null } });
|
|
683
|
+
else if (media?.pick) view.update({ level: media.pick.back, media: null, field: media.pick.multi ? listOf(media.pick.path) : media.pick.path, picked: false });
|
|
684
|
+
else view.update({ level: 'documents', media: null });
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
if (level === 'block') view.update({ level: 'document', field: null, picked: false });
|
|
688
|
+
// A case study or a shared block goes up to its collection's level, where it's listed (M5a).
|
|
689
|
+
else if (level === 'document' && (kind === 'fields' || kind === 'shared')) view.update({ level: 'collection', collection: doc.get().ref.collection, field: null, picked: false });
|
|
690
|
+
else if (level === 'document' || level === 'collection') view.update({ level: 'documents' });
|
|
691
|
+
else return false;
|
|
692
|
+
return true;
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
setWidth(width) {
|
|
696
|
+
if (view.get().width === width) return;
|
|
697
|
+
view.update({ width });
|
|
698
|
+
editor.announce(`Preview at ${width} width`);
|
|
699
|
+
},
|
|
700
|
+
|
|
701
|
+
setPop(pop) {
|
|
702
|
+
view.update({ pop });
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
setSideHidden(hidden) {
|
|
706
|
+
if ((ui.get().side === 'hidden') === hidden) return;
|
|
707
|
+
// Hiding it takes focus with it (display:none drops it to the body): the bar's button, which is still there to bring it back.
|
|
708
|
+
if (hidden && typeof document !== 'undefined' && document.getElementById('side')?.contains(document.activeElement)) {
|
|
709
|
+
document.querySelector<HTMLElement>('#bar .side-toggle')?.focus();
|
|
710
|
+
}
|
|
711
|
+
ui.set((state) => withSide(state, hidden));
|
|
712
|
+
editor.announce(hidden ? 'Sidebar hidden.' : 'Sidebar shown.');
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
setViewMode(collection, mode) {
|
|
716
|
+
ui.set((state) => withViewMode(state, collection, mode));
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
toggleGroup(key, open) {
|
|
720
|
+
ui.set((state) => withToggled(state, key, open ?? !state.open[key]));
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
reloadOpen: () => refreshOpen(),
|
|
724
|
+
|
|
725
|
+
async refreshDocuments() {
|
|
726
|
+
const response = await fetch(ENDPOINTS.documents, { headers: { Accept: 'application/json' } });
|
|
727
|
+
if (!response.ok) return;
|
|
728
|
+
documents.set((await response.json()) as ListedDocument[]);
|
|
729
|
+
// M6 Decision 9: the unread count rides along (absent when it couldn't be read).
|
|
730
|
+
const unread = response.headers.get(UNREAD_HEADER);
|
|
731
|
+
if (unread !== null && /^\d+$/.test(unread)) submissions.set({ unread: Number(unread) });
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
announce(text) {
|
|
735
|
+
live.set('');
|
|
736
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => live.set(text));
|
|
737
|
+
else live.set(text);
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
toast(text, icon) {
|
|
741
|
+
message.set({ id: (message.get()?.id ?? 0) + 1, text, icon: icon ?? null });
|
|
742
|
+
},
|
|
743
|
+
|
|
744
|
+
addressOf: (ref) => addressOf(ref, site.home),
|
|
745
|
+
|
|
746
|
+
async editHere() {
|
|
747
|
+
if (!liveSession) return;
|
|
748
|
+
const file = doc.get().entry.file;
|
|
749
|
+
const id = docId(doc.get().ref);
|
|
750
|
+
const answer = await liveSession.take(file);
|
|
751
|
+
if (answer.ok) {
|
|
752
|
+
lockOverride.set({ file, state: 'yours', lock: answer.lock.lock, after: liveSession.started() });
|
|
753
|
+
editor.announce("You're editing this here now.");
|
|
754
|
+
// The other tab's latest, unless this tab has edits of its own waiting.
|
|
755
|
+
if (!keeper?.blocked().some((b) => b.id === id) && !keeper?.dirty(id)) await refreshOpen();
|
|
756
|
+
} else if (answer.refusal) {
|
|
757
|
+
lockOverride.set({ file, state: answer.refusal.state, lock: answer.refusal.lock, after: liveSession.started() });
|
|
758
|
+
}
|
|
759
|
+
void liveSession.poll();
|
|
760
|
+
},
|
|
761
|
+
|
|
762
|
+
async takeOver() {
|
|
763
|
+
if (!liveSession) return;
|
|
764
|
+
const file = doc.get().entry.file;
|
|
765
|
+
const answer = await liveSession.takeOver(file);
|
|
766
|
+
if (!answer.ok) {
|
|
767
|
+
editor.announce("It didn't take over. Try again in a moment.");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
lockOverride.set({ file, state: 'yours', lock: answer.lock.lock, after: liveSession.started() });
|
|
771
|
+
// Open it as `drafts` has it now, with their changes committed.
|
|
772
|
+
await refreshOpen();
|
|
773
|
+
editor.announce('Taken over.');
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
async useMine() {
|
|
777
|
+
if (!liveSession || !keeper) return;
|
|
778
|
+
const a = access.get();
|
|
779
|
+
if (!a.held || a.lock === 'theirs') return;
|
|
780
|
+
if (a.lock === 'yours-elsewhere') {
|
|
781
|
+
const file = doc.get().entry.file;
|
|
782
|
+
const answer = await liveSession.take(file);
|
|
783
|
+
if (!answer.ok) return;
|
|
784
|
+
lockOverride.set({ file, state: 'yours', lock: answer.lock.lock, after: liveSession.started() });
|
|
785
|
+
}
|
|
786
|
+
keeper.useMine(docId(doc.get().ref));
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
async discardMine() {
|
|
790
|
+
if (!keeper) return;
|
|
791
|
+
keeper.discard(docId(doc.get().ref));
|
|
792
|
+
await refreshOpen();
|
|
793
|
+
editor.announce('Your held changes were discarded.');
|
|
794
|
+
},
|
|
795
|
+
|
|
796
|
+
async publish() {
|
|
797
|
+
if (!liveSession) return { ok: false };
|
|
798
|
+
await keeper?.flush();
|
|
799
|
+
const answer = await liveSession.publish();
|
|
800
|
+
if (!answer.ok) {
|
|
801
|
+
editor.announce("It didn't publish. Nothing went live.");
|
|
802
|
+
return { ok: false, error: answer.error };
|
|
803
|
+
}
|
|
804
|
+
if (answer.body.result === 'nothing-waiting') editor.announce('Nothing to publish.');
|
|
805
|
+
else editor.announce('Publishing.');
|
|
806
|
+
return { ok: true };
|
|
807
|
+
},
|
|
808
|
+
|
|
809
|
+
async tryAgain() {
|
|
810
|
+
if (!liveSession) return { ok: false };
|
|
811
|
+
const answer = await liveSession.tryAgain();
|
|
812
|
+
if (answer.ok) editor.announce('Trying again.');
|
|
813
|
+
return answer.ok ? { ok: true } : { ok: false, error: answer.error };
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
dismissNotice(id) {
|
|
817
|
+
void liveSession?.dismiss(id);
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
async signOut() {
|
|
821
|
+
if (!liveSession) return;
|
|
822
|
+
await keeper?.flush();
|
|
823
|
+
const location = await liveSession.signOut();
|
|
824
|
+
window.location.assign(location);
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
signInAgain() {
|
|
828
|
+
const out = liveState.get().signedOut;
|
|
829
|
+
window.open(out?.signIn ?? '/admin/login', '_blank', 'noopener');
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
timeZone: () => liveState.get().status?.timeZone ?? site.timeZone ?? 'UTC',
|
|
833
|
+
|
|
834
|
+
start() {
|
|
835
|
+
const stops: (() => void)[] = [];
|
|
836
|
+
const id = docId(doc.get().ref);
|
|
837
|
+
|
|
838
|
+
// Keeping: this document's file and version; then unsent edits newer than their files.
|
|
839
|
+
if (keeper) {
|
|
840
|
+
adoptHeld(id, doc.get().ref, opened);
|
|
841
|
+
const fileOf = async (other: string): Promise<{ version: string; text: string; lastChange?: OpenedDocument['lastChange'] } | null> => {
|
|
842
|
+
const slash = other.indexOf('/');
|
|
843
|
+
const response = await fetch(
|
|
844
|
+
`${ENDPOINTS.document}?collection=${encodeURIComponent(other.slice(0, slash))}&path=${encodeURIComponent(other.slice(slash + 1))}`,
|
|
845
|
+
);
|
|
846
|
+
if (response.status === 404 || response.status === 400) return null;
|
|
847
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
848
|
+
const { version, text, lastChange } = (await response.json()) as OpenedDocument;
|
|
849
|
+
return { version, text, lastChange };
|
|
850
|
+
};
|
|
851
|
+
void keeper.recoverOthers(id, fileOf).finally(() => keeper.settle());
|
|
852
|
+
const onPageHide = () => keeper.pagehide();
|
|
853
|
+
window.addEventListener('pagehide', onPageHide);
|
|
854
|
+
stops.push(() => window.removeEventListener('pagehide', onPageHide));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Live mode: the poll, and what its answers set off.
|
|
858
|
+
if (liveSession && keeper) {
|
|
859
|
+
stops.push(liveSession.start());
|
|
860
|
+
stops.push(
|
|
861
|
+
access.subscribe((next, previous) => {
|
|
862
|
+
const openId = docId(doc.get().ref);
|
|
863
|
+
// Someone else's lock lapsed: say so (the mockup's #lapsed toast).
|
|
864
|
+
const title = doc.get().document.title || doc.get().entry.label;
|
|
865
|
+
const lapsed = lapsedToast(previous, next, doc.get().entry.file, title);
|
|
866
|
+
if (lapsed) editor.toast(lapsed, 'check');
|
|
867
|
+
const taken = takenOverToast(previous, next, doc.get().entry.file, title);
|
|
868
|
+
if (taken) editor.toast(taken, 'lock');
|
|
869
|
+
// The lock lapsed, or Edit here took it: editable again, without a reload (Decision 9).
|
|
870
|
+
if (previous.veil && !next.veil) {
|
|
871
|
+
if (keeper.blocked().some((b) => b.id === openId && b.failure === 'locked')) void recheck(openId);
|
|
872
|
+
else if (!keeper.dirty(openId)) void refreshOpen();
|
|
873
|
+
}
|
|
874
|
+
// Signed in again (from another tab): held edits go out under the re-send rule (Decision 11).
|
|
875
|
+
if (previous.signedOut && !next.signedOut) {
|
|
876
|
+
for (const b of keeper.blocked()) if (b.failure === 'signedout') void recheck(b.id);
|
|
877
|
+
preview?.retry(); // a render dropped while signed out goes now
|
|
878
|
+
editor.announce('Signed in again.');
|
|
879
|
+
}
|
|
880
|
+
}),
|
|
881
|
+
);
|
|
882
|
+
stops.push(
|
|
883
|
+
liveState.subscribe((next, previous) => {
|
|
884
|
+
// The server answers again (a deploy finished): what couldn't reach it goes now.
|
|
885
|
+
if (next.reachable && !previous.reachable && keeper.status.get().state === 'offline') keeper.tryNow();
|
|
886
|
+
// Someone else is changing the locked document: show their latest.
|
|
887
|
+
const open = next.status?.open;
|
|
888
|
+
const openId = docId(doc.get().ref);
|
|
889
|
+
if (!open || open.file !== doc.get().entry.file) return;
|
|
890
|
+
const change = open.lastChange.version;
|
|
891
|
+
const seen = seenChange;
|
|
892
|
+
seenChange = change;
|
|
893
|
+
if (access.get().veil && change && change !== seen && change !== loadedVersion && !keeper.dirty(openId)) void refreshOpen();
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// The brand (M5a Decision 10): the poll carries Site Settings' as committed; the chrome follows it.
|
|
899
|
+
if (liveSession) {
|
|
900
|
+
stops.push(
|
|
901
|
+
liveState.subscribe((next) => {
|
|
902
|
+
const b = next.status?.brand;
|
|
903
|
+
const now = brand.get();
|
|
904
|
+
if (b && (b.name !== now.name || b.accent !== now.accent || b.logo !== now.logo || b.icon !== now.icon || b.tabIcon !== now.tabIcon)) brand.set(b);
|
|
905
|
+
}),
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
stops.push(
|
|
909
|
+
brand.subscribe((b) => {
|
|
910
|
+
const root = document.documentElement.style;
|
|
911
|
+
root.setProperty('--accent', b.accent);
|
|
912
|
+
root.setProperty('--accent-ink', b.accentInk);
|
|
913
|
+
root.setProperty('--accent-edge', b.accentEdge);
|
|
914
|
+
// The tab's icon follows Site Settings' icon or logo (M5b Decision 11).
|
|
915
|
+
const tab = b.tabIcon ?? b.icon ?? b.logo;
|
|
916
|
+
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
917
|
+
if (tab && link && link.getAttribute('href') !== tab) link.setAttribute('href', tab);
|
|
918
|
+
setTitle();
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
// The document list follows what changes it (M5a Phase 5): in live mode another
|
|
923
|
+
// tab's new or deleted document (the waiting list's files and changes move), a
|
|
924
|
+
// Publish; in both modes this tab's own changes once kept (titles, a shared block's
|
|
925
|
+
// "Used on"). The last list stays on screen while it's read again (no flicker).
|
|
926
|
+
let refreshing = false;
|
|
927
|
+
let again = false;
|
|
928
|
+
const refresh = () => {
|
|
929
|
+
if (refreshing) {
|
|
930
|
+
again = true;
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
refreshing = true;
|
|
934
|
+
void editor
|
|
935
|
+
.refreshDocuments()
|
|
936
|
+
.catch(() => undefined)
|
|
937
|
+
.finally(() => {
|
|
938
|
+
refreshing = false;
|
|
939
|
+
if (again) {
|
|
940
|
+
again = false;
|
|
941
|
+
refresh();
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
};
|
|
945
|
+
const waitingKey = (st: LiveState) => (st.status?.waiting ?? []).map((w) => `${w.file}:${w.change}`).sort().join('\n');
|
|
946
|
+
if (liveSession) stops.push(liveState.subscribe((next, previous) => next.status && previous.status && waitingKey(next) !== waitingKey(previous) && refresh()));
|
|
947
|
+
stops.push(keep.subscribe((next, previous) => next.state === 'kept' && previous.state !== 'kept' && refresh()));
|
|
948
|
+
|
|
949
|
+
// The cookie follows the sidebar's per-browser state (Decision 20).
|
|
950
|
+
stops.push(ui.subscribe((state) => (document.cookie = uiCookieString(state))));
|
|
951
|
+
document.cookie = uiCookieString(ui.get());
|
|
952
|
+
|
|
953
|
+
// Addresses (Decision 19): the address names the open document; back and forward open theirs.
|
|
954
|
+
// M6: the inbox's own address (`/admin/submissions[/<id>]`) stays as it is.
|
|
955
|
+
const canonical = view.get().level === 'submissions' ? inboxAddress(view.get().inbox?.id ?? null) : addressOf(doc.get().ref, site.home);
|
|
956
|
+
if (location.pathname !== canonical) history.replaceState({ caracms: id }, '', canonical + location.search);
|
|
957
|
+
const onPopState = () => {
|
|
958
|
+
const inbox = inboxFromAddress(location.pathname);
|
|
959
|
+
if (inbox && inbox !== 'bad') {
|
|
960
|
+
editor.openInbox(inbox.id, { history: 'none' });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const ref = refFromAddress(location.pathname, site.collections.map((c) => c.name), site.home);
|
|
964
|
+
if (ref && site.collections.some((c) => c.name === ref.collection)) void editor.openDocument(ref, { history: 'none' });
|
|
965
|
+
};
|
|
966
|
+
window.addEventListener('popstate', onPopState);
|
|
967
|
+
stops.push(() => window.removeEventListener('popstate', onPopState));
|
|
968
|
+
setTitle();
|
|
969
|
+
stops.push(doc.state.subscribe(setTitle));
|
|
970
|
+
stops.push(view.subscribe((next, previous) => next.level !== previous.level && setTitle()));
|
|
971
|
+
|
|
972
|
+
return () => stops.forEach((stop) => stop());
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
return editor;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** A 401's body as the signed-out state (as live.ts reads it). */
|
|
979
|
+
function signedOutFrom(body: unknown): SignedOutError {
|
|
980
|
+
const b = (body && typeof body === 'object' ? body : {}) as Partial<SignedOutError>;
|
|
981
|
+
return { error: b.error ?? 'signed-out', code: 'signed-out', reason: b.reason === 'removed' ? 'removed' : 'signed-out', signIn: b.signIn ?? '/admin/login' };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** A list row's path → the list's: `images.6` → `images`. */
|
|
985
|
+
const listOf = (path: string) => (path.includes('.') ? path.slice(0, path.lastIndexOf('.')) : path);
|
|
986
|
+
|
|
987
|
+
function safeStorage(): Storage | null {
|
|
988
|
+
try {
|
|
989
|
+
const s = window.localStorage;
|
|
990
|
+
s.getItem('caracms:probe');
|
|
991
|
+
return s;
|
|
992
|
+
} catch {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
}
|