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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session in live mode (M4 Decisions 11 and 14; the mockup's #account,
|
|
3
|
+
* #signed-out-edit and #offline):
|
|
4
|
+
*
|
|
5
|
+
* - `AccountButton` / `AccountPop`: the signed-in avatar in the bar, opening who is
|
|
6
|
+
* signed in and **Sign out** (`POST /admin/login/signout`, then its `location`).
|
|
7
|
+
* - `SignedOutModal`: "You've been signed out". The edits stay in the tab; **Sign in
|
|
8
|
+
* again** opens sign-in in a new tab, and this tab sends them once its next poll
|
|
9
|
+
* answers (editor.ts → recheck). A person removed from the whitelist gets the
|
|
10
|
+
* plain sentence that their latest changes couldn't be kept, and no way back.
|
|
11
|
+
* - `OfflineExtras`: the offline note's live-mode lines: the unsent count, "Trying
|
|
12
|
+
* again in 12 s." (the retry timer runs only while offline) and **Try now**.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
16
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
17
|
+
import { displayName } from './format.ts';
|
|
18
|
+
import { Avatar } from './marks.tsx';
|
|
19
|
+
|
|
20
|
+
export function AccountButton() {
|
|
21
|
+
const editor = useEditor();
|
|
22
|
+
const pop = useStore(editor.view, (v) => v.pop);
|
|
23
|
+
const you = useStore(editor.liveState, (s) => s.status?.you ?? editor.you);
|
|
24
|
+
if (!you) return <span data-slot="account" hidden />;
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
className="me"
|
|
29
|
+
data-slot="account"
|
|
30
|
+
aria-haspopup="dialog"
|
|
31
|
+
aria-expanded={pop === 'account'}
|
|
32
|
+
aria-label={`Signed in as ${displayName(you)}`}
|
|
33
|
+
onClick={() => editor.setPop(pop === 'account' ? null : 'account')}
|
|
34
|
+
>
|
|
35
|
+
<Avatar person={you} />
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function AccountPop() {
|
|
41
|
+
const editor = useEditor();
|
|
42
|
+
const you = useStore(editor.liveState, (s) => s.status?.you ?? editor.you);
|
|
43
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
44
|
+
const [leaving, setLeaving] = useState(false);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const onDown = (event: MouseEvent) => {
|
|
47
|
+
const target = event.target as Element | null;
|
|
48
|
+
if (!target || ref.current?.contains(target) || target.closest?.('.me')) return;
|
|
49
|
+
editor.setPop(null);
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener('mousedown', onDown);
|
|
52
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
53
|
+
}, [editor]);
|
|
54
|
+
if (!you) return null;
|
|
55
|
+
return (
|
|
56
|
+
<div ref={ref} className="pop" role="dialog" aria-label="Account" data-pop="account" style={{ right: 8, width: 280 }}>
|
|
57
|
+
<div className="acct">
|
|
58
|
+
<div className="who">
|
|
59
|
+
<Avatar person={you} size="lg" />
|
|
60
|
+
<div>
|
|
61
|
+
<b>{displayName(you)}</b>
|
|
62
|
+
<small>{you.email}</small>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<hr />
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="btn block"
|
|
69
|
+
data-act="sign-out"
|
|
70
|
+
disabled={leaving}
|
|
71
|
+
onClick={() => {
|
|
72
|
+
setLeaving(true);
|
|
73
|
+
void editor.signOut();
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<Icon name="signout" size="sm" />
|
|
77
|
+
Sign out
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function SignedOutModal() {
|
|
85
|
+
const editor = useEditor();
|
|
86
|
+
const signedOut = useStore(editor.liveState, (s) => s.signedOut);
|
|
87
|
+
const unsent = useStore(editor.keep, (k) => k.unsent ?? 0);
|
|
88
|
+
const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
|
|
89
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (signedOut) button.current?.focus();
|
|
92
|
+
}, [signedOut]);
|
|
93
|
+
if (!signedOut) return null;
|
|
94
|
+
if (signedOut.reason === 'removed') {
|
|
95
|
+
return (
|
|
96
|
+
<div className="scrim" data-modal="removed">
|
|
97
|
+
<div className="dialog" role="alertdialog" aria-modal="true" aria-labelledby="dlg-h" aria-describedby="dlg-d">
|
|
98
|
+
<div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
|
|
99
|
+
<Icon name="signout" />
|
|
100
|
+
</div>
|
|
101
|
+
<h2 id="dlg-h">You can't sign in to {editor.brand.get().name} any more</h2>
|
|
102
|
+
<p id="dlg-d">You can no longer sign in, so your latest changes couldn't be kept.</p>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return (
|
|
108
|
+
<div className="scrim" data-modal="signed-out">
|
|
109
|
+
<div className="dialog" role="alertdialog" aria-modal="true" aria-labelledby="dlg-h">
|
|
110
|
+
<div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
|
|
111
|
+
<Icon name="signout" />
|
|
112
|
+
</div>
|
|
113
|
+
<h2 id="dlg-h">You've been signed out</h2>
|
|
114
|
+
{unsent > 0 ? (
|
|
115
|
+
<>
|
|
116
|
+
<p>
|
|
117
|
+
{unsent} change{unsent === 1 ? '' : 's'} to {title} {unsent === 1 ? "hasn't" : "haven't"} reached the server.
|
|
118
|
+
</p>
|
|
119
|
+
<p>Keep this tab open and sign in again.</p>
|
|
120
|
+
</>
|
|
121
|
+
) : (
|
|
122
|
+
<p>Sign in again to keep editing.</p>
|
|
123
|
+
)}
|
|
124
|
+
<div className="acts">
|
|
125
|
+
<button ref={button} type="button" className="btn ink" data-act="signin-again" onClick={() => editor.signInAgain()}>
|
|
126
|
+
<Icon name="ext" size="sm" />
|
|
127
|
+
Sign in again
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
<p className="field-help" style={{ marginTop: 'var(--space-3)', textAlign: 'right' }}>
|
|
131
|
+
Opens in a new tab.
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The offline note's countdown, Try now and the unsent count (live mode; M3 left their slots). */
|
|
139
|
+
export function OfflineExtras() {
|
|
140
|
+
const editor = useEditor();
|
|
141
|
+
const retryAt = useStore(editor.keep, (k) => k.retryAt ?? null);
|
|
142
|
+
const [now, setNow] = useState(() => Date.now());
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (retryAt === null) return;
|
|
145
|
+
setNow(Date.now());
|
|
146
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
147
|
+
return () => clearInterval(t);
|
|
148
|
+
}, [retryAt]);
|
|
149
|
+
const seconds = retryAt === null ? null : Math.max(0, Math.ceil((retryAt - now) / 1000));
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<p style={{ marginTop: 6 }} data-slot="retry-countdown">
|
|
153
|
+
{seconds === null || seconds === 0 ? 'Trying again…' : `Trying again in ${seconds} s.`}
|
|
154
|
+
</p>
|
|
155
|
+
<div className="acts" data-slot="try-now">
|
|
156
|
+
<button type="button" className="btn sm" data-act="try-now" onClick={() => editor.keeper?.tryNow()}>
|
|
157
|
+
Try now
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's browser entry: hydrate the server-rendered shell (Decision 2) from
|
|
3
|
+
* the props the admin route left in the page.
|
|
4
|
+
*/
|
|
5
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
6
|
+
import type { AdminProps } from '../types.ts';
|
|
7
|
+
import { createEditor } from './editor.ts';
|
|
8
|
+
import { EditorApp } from './shell/App.tsx';
|
|
9
|
+
|
|
10
|
+
const props = JSON.parse(document.getElementById('caracms-props')!.textContent!) as AdminProps;
|
|
11
|
+
const editor = createEditor(props, { client: true });
|
|
12
|
+
hydrateRoot(document.getElementById('viewport')!, <EditorApp editor={editor} />);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage under the library (M5b; the mockup's #library and #library-image): the last
|
|
3
|
+
* page stays, dimmed (shell/Stage.tsx), and this sits over it: the big drop target for
|
|
4
|
+
* images, clips and files, or, on an image's own level, the whole picture, large; on a
|
|
5
|
+
* file's (M6c G4, the mockup's #file), its type tile, large, with Open in a new tab.
|
|
6
|
+
*/
|
|
7
|
+
import { fileAddress, isFileKey } from '../../../files.ts';
|
|
8
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
9
|
+
import { useDrop } from '../fields/MediaField.tsx';
|
|
10
|
+
import { FileTile, UploadButton } from '../pane/levels/MediaLevel.tsx';
|
|
11
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
12
|
+
import { clipId, clipStill, fileSize, mediaFor } from './store.ts';
|
|
13
|
+
|
|
14
|
+
const TAKES = 'JPEG, PNG, WebP, AVIF, HEIC, GIF or SVG';
|
|
15
|
+
|
|
16
|
+
export function LibraryStage({ back, onBack }: { back: string; onBack: () => void }) {
|
|
17
|
+
const editor = useEditor();
|
|
18
|
+
const media = mediaFor(editor);
|
|
19
|
+
const item = useStore(editor.view, (v) => v.media?.item ?? null);
|
|
20
|
+
const images = useStore(media.images, (l) => l.list);
|
|
21
|
+
const clips = useStore(media.clips, (l) => l.list);
|
|
22
|
+
const files = useStore(media.files, (l) => l.list);
|
|
23
|
+
const drop = useDrop((list) => media.upload(list));
|
|
24
|
+
const file = item && isFileKey(item) ? files?.find((f) => f.key === item) : null;
|
|
25
|
+
if (file) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="stage-lb" data-file-stage="">
|
|
28
|
+
<FileTile type={file.type} size="lg" />
|
|
29
|
+
<h2>{file.name}</h2>
|
|
30
|
+
<p className="fine">
|
|
31
|
+
{file.type}
|
|
32
|
+
{file.bytes !== null ? ` · ${fileSize(file.bytes)}` : ''}
|
|
33
|
+
</p>
|
|
34
|
+
<a className="btn" href={fileAddress(file.key)} target="_blank" rel="noopener">
|
|
35
|
+
<Icon name="ext" size="sm" />
|
|
36
|
+
Open in a new tab
|
|
37
|
+
</a>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (item) {
|
|
42
|
+
const image = images?.find((x) => x.key === item);
|
|
43
|
+
const clip = clips?.find((c) => clipId(c) === item);
|
|
44
|
+
const x = image ?? clip;
|
|
45
|
+
if (x) {
|
|
46
|
+
const ratio = x.w && x.h ? (x.w / x.h).toFixed(4) : '1.5';
|
|
47
|
+
const src = image ? image.thumbnail2x : clip?.playbackId ? clipStill(clip.playbackId, 0, 1280) : null;
|
|
48
|
+
return (
|
|
49
|
+
<div className="stage-lb">
|
|
50
|
+
<span className="ph" style={{ ['--r' as string]: ratio }}>
|
|
51
|
+
{src ? <img className="ph-img" src={src} alt="" /> : null}
|
|
52
|
+
</span>
|
|
53
|
+
<p className="cap">
|
|
54
|
+
<b>{image ? image.name : (clip?.title ?? 'A clip')}</b>
|
|
55
|
+
{x.w && x.h ? ` · ${x.w} × ${x.h}` : ''}
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
<div className={`stage-drop${drop.over ? ' is-over' : ''}`} data-drop="lib" {...drop.props}>
|
|
63
|
+
<Icon name="upload" />
|
|
64
|
+
<h2>Drop images, clips or files here</h2>
|
|
65
|
+
<p className="fine">{TAKES}, up to 40 MB and 50 megapixels. Files: PDF, Word, Excel, PowerPoint, CSV and Zip, up to 40 MB.</p>
|
|
66
|
+
<div className="acts">
|
|
67
|
+
<UploadButton className="btn" />
|
|
68
|
+
<button type="button" className="btn ghost" onClick={onBack}>
|
|
69
|
+
<Icon name="chev-l" size="sm" />
|
|
70
|
+
Back to {back}
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace… on a file (M6c G4, Decision 9; the mockup's #replace, #replace-uploading,
|
|
3
|
+
* #replace-ready, #replace-blocked, #replace-same, #replace-failed, #replace-done), in the
|
|
4
|
+
* editor's `#modal-root` (documents/dialogs.tsx → `{ kind: 'replace', key }`).
|
|
5
|
+
*
|
|
6
|
+
* 1. **Choose**: drop or choose the new version, any type the library takes.
|
|
7
|
+
* 2. **Uploading**: its real progress (media/store.ts → `uploadFile`); nothing changes yet.
|
|
8
|
+
* 3. **Ready**: the old file → the new one, every document that will change (the
|
|
9
|
+
* listing's `usedIn`), that they become waiting changes (live mode), and that the old
|
|
10
|
+
* address keeps working.
|
|
11
|
+
* 4. **Replace and update N links** (documents/actions.ts → `replaceFile`): refused while
|
|
12
|
+
* this tab holds unsent edits (it can start once they've gone through: the button wakes
|
|
13
|
+
* when the bar says Kept); in live mode a failure part way leaves every document as it
|
|
14
|
+
* was ("Nothing was changed", Try again).
|
|
15
|
+
* 5. **Done**: where the links moved; the old file stays in the library, at its address.
|
|
16
|
+
*
|
|
17
|
+
* **The same bytes as the old file** (the upload answered with the old file's own key):
|
|
18
|
+
* there's nothing to replace, and it says so. Bytes already in the library under another
|
|
19
|
+
* name are fine: Replace uses that file (Decision 9), which is also how a retry after a
|
|
20
|
+
* failed or cancelled Replace works, the new bytes having gone up first.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
24
|
+
import { closeDialog, Frame } from '../documents/dialogs.tsx';
|
|
25
|
+
import { replaceFile } from '../documents/actions.ts';
|
|
26
|
+
import { useDrop } from '../fields/MediaField.tsx';
|
|
27
|
+
import { FileTile, FILE_ACCEPT, rowDate, timeZoneOf } from '../pane/levels/MediaLevel.tsx';
|
|
28
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
29
|
+
import type { KeepStatus } from '../state/keeper.ts';
|
|
30
|
+
import { createStore } from '../state/store.ts';
|
|
31
|
+
import type { FileUse, LibraryFile } from '../../types.ts';
|
|
32
|
+
import { fileSize, mediaFor } from './store.ts';
|
|
33
|
+
|
|
34
|
+
type Phase =
|
|
35
|
+
| { at: 'choose' }
|
|
36
|
+
| { at: 'uploading'; name: string; bytes: number; tag: string }
|
|
37
|
+
| { at: 'ready'; to: LibraryFile; busy?: boolean; failed?: string; blocked?: string }
|
|
38
|
+
| { at: 'same'; name: string; match: LibraryFile }
|
|
39
|
+
| { at: 'done'; to: LibraryFile; relinked: FileUse[] };
|
|
40
|
+
|
|
41
|
+
const links = (n: number) => `${n} link${n === 1 ? '' : 's'}`;
|
|
42
|
+
const docs = (n: number) => `${n} document${n === 1 ? '' : 's'}`;
|
|
43
|
+
/** "Services", "Services and About", "Services, About and Contact". */
|
|
44
|
+
const names = (list: readonly string[]) => (list.length <= 1 ? (list[0] ?? '') : `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`);
|
|
45
|
+
|
|
46
|
+
let tags = 0;
|
|
47
|
+
|
|
48
|
+
export function ReplaceDialog({ fileKey }: { fileKey: string }) {
|
|
49
|
+
const editor = useEditor();
|
|
50
|
+
const media = mediaFor(editor);
|
|
51
|
+
const files = useStore(media.files, (l) => l.list);
|
|
52
|
+
const uploads = useStore(media.uploads);
|
|
53
|
+
const kept = useMemo(() => createStore<KeepStatus>({ state: 'kept', error: null }), []);
|
|
54
|
+
const keep = useStore(editor.keeper?.status ?? kept, (k) => k.state);
|
|
55
|
+
const [phase, setPhase] = useState<Phase>({ at: 'choose' });
|
|
56
|
+
const [error, setError] = useState<string | null>(null);
|
|
57
|
+
const input = useRef<HTMLInputElement>(null);
|
|
58
|
+
const old = files?.find((f) => f.key === fileKey) ?? null;
|
|
59
|
+
const live = editor.mode === 'live';
|
|
60
|
+
const zone = timeZoneOf(editor);
|
|
61
|
+
|
|
62
|
+
// Blocked by unsent edits: the button wakes once the bar says Kept (the keeper retries on its own).
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (phase.at === 'ready' && phase.blocked && keep === 'kept') setPhase({ ...phase, blocked: undefined });
|
|
65
|
+
}, [keep, phase]);
|
|
66
|
+
|
|
67
|
+
const take = async (file: File) => {
|
|
68
|
+
setError(null);
|
|
69
|
+
const tag = `replace-${++tags}`;
|
|
70
|
+
setPhase({ at: 'uploading', name: file.name, bytes: file.size, tag });
|
|
71
|
+
const sent = await media.uploadFile(file, tag);
|
|
72
|
+
if (!sent.ok) {
|
|
73
|
+
setPhase({ at: 'choose' });
|
|
74
|
+
if (sent.error) setError(sent.error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// The same bytes are the same key: the old file itself, so nothing to replace. A different file already in
|
|
78
|
+
// the library (`created: false`, e.g. the new bytes from an earlier try) is used as it is.
|
|
79
|
+
if (sent.file.key === fileKey) setPhase({ at: 'same', name: file.name, match: sent.file });
|
|
80
|
+
else setPhase({ at: 'ready', to: sent.file });
|
|
81
|
+
};
|
|
82
|
+
const drop = useDrop((list) => {
|
|
83
|
+
if (list[0] && (phase.at === 'choose' || phase.at === 'same' || phase.at === 'ready')) void take(list[0]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!old) return null;
|
|
87
|
+
const uses = old.usedIn;
|
|
88
|
+
const total = uses.reduce((n, u) => n + u.links, 0);
|
|
89
|
+
|
|
90
|
+
const go = async () => {
|
|
91
|
+
if (phase.at !== 'ready') return;
|
|
92
|
+
setPhase({ ...phase, busy: true, failed: undefined });
|
|
93
|
+
const answer = await replaceFile(editor, old.key, phase.to.key);
|
|
94
|
+
if (answer.ok) {
|
|
95
|
+
void media.loadImages();
|
|
96
|
+
setPhase({ at: 'done', to: phase.to, relinked: answer.body.relinked });
|
|
97
|
+
const n = answer.body.relinked.reduce((k, r) => k + r.links, 0);
|
|
98
|
+
editor.announce(n ? `${links(n)} now point at ${phase.to.name}.` : `Nothing linked to ${old.name}.`);
|
|
99
|
+
} else if (answer.code === 'unsent') setPhase({ ...phase, busy: false, blocked: answer.error });
|
|
100
|
+
else setPhase({ ...phase, busy: false, failed: answer.error });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const oldRow = (
|
|
104
|
+
<div className="frow old">
|
|
105
|
+
<FileTile />
|
|
106
|
+
<span className="ft">
|
|
107
|
+
<b>{old.name}</b>
|
|
108
|
+
<small>
|
|
109
|
+
<span className="ty">{old.type}</span>
|
|
110
|
+
{old.bytes !== null ? ` · ${fileSize(old.bytes)}` : ''}
|
|
111
|
+
{old.uploadedAt ? ` · ${rowDate(old.uploadedAt, zone)}` : ''} · linked from {uses.length}
|
|
112
|
+
</small>
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
const arrow = (
|
|
117
|
+
<div className="arrow">
|
|
118
|
+
<Icon name="down" size="xs" />
|
|
119
|
+
replaced by
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
const change = (
|
|
123
|
+
<button type="button" className="btn sm ghost" onClick={() => input.current?.click()}>
|
|
124
|
+
Change…
|
|
125
|
+
</button>
|
|
126
|
+
);
|
|
127
|
+
const picker = (
|
|
128
|
+
<input
|
|
129
|
+
ref={input}
|
|
130
|
+
type="file"
|
|
131
|
+
hidden
|
|
132
|
+
accept={FILE_ACCEPT}
|
|
133
|
+
data-replace-file=""
|
|
134
|
+
onChange={(e) => {
|
|
135
|
+
const file = e.target.files?.[0];
|
|
136
|
+
e.target.value = '';
|
|
137
|
+
if (file) void take(file);
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
const head = (
|
|
142
|
+
<>
|
|
143
|
+
<h2 id="dlg-h">Replace {old.name}</h2>
|
|
144
|
+
<p>Every link moves to the new version.</p>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
const cancel = (
|
|
148
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
149
|
+
Cancel
|
|
150
|
+
</button>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (phase.at === 'done') {
|
|
154
|
+
const moved = phase.relinked.reduce((n, r) => n + r.links, 0);
|
|
155
|
+
return (
|
|
156
|
+
<Frame label="replace-done">
|
|
157
|
+
<div className="icon-h live">
|
|
158
|
+
<Icon name="check" />
|
|
159
|
+
</div>
|
|
160
|
+
<h2 id="dlg-h">{moved ? 'The links now point at the new file' : 'Nothing linked to the old file'}</h2>
|
|
161
|
+
{moved ? (
|
|
162
|
+
<p>
|
|
163
|
+
{links(moved)} in {names(phase.relinked.map((r) => r.title))} point at <b style={{ color: 'var(--text)' }}>{phase.to.name}</b>.{' '}
|
|
164
|
+
{live ? 'Live at the next Publish.' : 'The pages are saved with the new address.'}
|
|
165
|
+
</p>
|
|
166
|
+
) : null}
|
|
167
|
+
{phase.relinked.length ? (
|
|
168
|
+
<div className="affects" style={{ background: 'var(--surface)' }}>
|
|
169
|
+
<ul style={{ margin: 0 }}>
|
|
170
|
+
{phase.relinked.map((r) => (
|
|
171
|
+
<li key={`${r.collection}/${r.path}`}>
|
|
172
|
+
<Icon name="page" size="sm" />
|
|
173
|
+
<span className="nm">{r.title}</span>
|
|
174
|
+
<span className="meta">{links(r.links)}</span>
|
|
175
|
+
{live ? <span className="dot" title="Changes waiting" /> : null}
|
|
176
|
+
</li>
|
|
177
|
+
))}
|
|
178
|
+
</ul>
|
|
179
|
+
</div>
|
|
180
|
+
) : null}
|
|
181
|
+
<div className="acts">
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="btn"
|
|
185
|
+
onClick={() => {
|
|
186
|
+
closeDialog(editor);
|
|
187
|
+
editor.openMedia({ item: phase.to.key, tab: 'files' });
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
Open the new file
|
|
191
|
+
</button>
|
|
192
|
+
<button type="button" className="btn ink" data-autofocus="" onClick={() => closeDialog(editor)}>
|
|
193
|
+
Done
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
</Frame>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let middle;
|
|
201
|
+
let note = null;
|
|
202
|
+
let action = (
|
|
203
|
+
<button type="button" className="btn ink" disabled>
|
|
204
|
+
Replace
|
|
205
|
+
</button>
|
|
206
|
+
);
|
|
207
|
+
if (phase.at === 'choose') {
|
|
208
|
+
middle = (
|
|
209
|
+
<div className="swap">
|
|
210
|
+
{oldRow}
|
|
211
|
+
{arrow}
|
|
212
|
+
<button type="button" className={`mdrop${drop.over ? ' is-over' : ''}`} data-replace-drop="" onClick={() => input.current?.click()} {...drop.props}>
|
|
213
|
+
<Icon name="upload" />
|
|
214
|
+
<b>Drop the new version here</b>
|
|
215
|
+
<span>or choose a file</span>
|
|
216
|
+
<span className="fine">PDF, Word, Excel, PowerPoint, CSV or Zip, up to 40 MB</span>
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
if (error)
|
|
221
|
+
note = (
|
|
222
|
+
<div className="note danger" role="alert">
|
|
223
|
+
<Icon name="warn" />
|
|
224
|
+
<div>
|
|
225
|
+
<b>That file wasn’t taken</b>
|
|
226
|
+
<p>{error}</p>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
} else if (phase.at === 'uploading') {
|
|
231
|
+
const pct = uploads.find((u) => u.id === phase.tag)?.pct ?? 0;
|
|
232
|
+
middle = (
|
|
233
|
+
<div className="swap">
|
|
234
|
+
{oldRow}
|
|
235
|
+
{arrow}
|
|
236
|
+
<div className="frow new" role="status" aria-label={`${phase.name}, uploading, ${pct}%`}>
|
|
237
|
+
<FileTile />
|
|
238
|
+
<span className="ft">
|
|
239
|
+
<b>{phase.name}</b>
|
|
240
|
+
<small>
|
|
241
|
+
{fileSize(phase.bytes)} · {pct ? `Uploading · ${pct}%` : 'Waiting…'}
|
|
242
|
+
</small>
|
|
243
|
+
<span className="mbar">
|
|
244
|
+
<i style={{ ['--p' as string]: `${pct}%` }} />
|
|
245
|
+
</span>
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
} else if (phase.at === 'same') {
|
|
251
|
+
middle = (
|
|
252
|
+
<div className="swap" {...drop.props}>
|
|
253
|
+
{oldRow}
|
|
254
|
+
{arrow}
|
|
255
|
+
<div className="frow new">
|
|
256
|
+
<FileTile />
|
|
257
|
+
<span className="ft">
|
|
258
|
+
<b>{phase.name}</b>
|
|
259
|
+
<small>
|
|
260
|
+
<span className="ty">{phase.match.type}</span>
|
|
261
|
+
{phase.match.bytes !== null ? ` · ${fileSize(phase.match.bytes)}` : ''} · the same file
|
|
262
|
+
</small>
|
|
263
|
+
</span>
|
|
264
|
+
{change}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
note = (
|
|
269
|
+
<div className="note" role="status" data-replace-same="">
|
|
270
|
+
<Icon name="copy" />
|
|
271
|
+
<div>
|
|
272
|
+
<b>That’s the file that’s already here</b>
|
|
273
|
+
<p>Same contents as {old.name}. Choose the new version.</p>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
} else {
|
|
278
|
+
middle = (
|
|
279
|
+
<>
|
|
280
|
+
<div className="swap" {...drop.props}>
|
|
281
|
+
{oldRow}
|
|
282
|
+
{arrow}
|
|
283
|
+
<div className="frow new">
|
|
284
|
+
<FileTile />
|
|
285
|
+
<span className="ft">
|
|
286
|
+
<b>{phase.to.name}</b>
|
|
287
|
+
<small>
|
|
288
|
+
<span className="ty">{phase.to.type}</span>
|
|
289
|
+
{phase.to.bytes !== null ? ` · ${fileSize(phase.to.bytes)}` : ''} · <span className="new">Just uploaded</span>
|
|
290
|
+
</small>
|
|
291
|
+
</span>
|
|
292
|
+
{phase.busy ? null : change}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div className="affects">
|
|
296
|
+
<b>{total ? `Updates ${links(total)} in ${docs(uses.length)}` : 'Nothing links to it yet'}</b>
|
|
297
|
+
<p>{total ? (live ? 'Live at the next Publish.' : 'Each page is saved with the new address.') : 'No page changes.'}</p>
|
|
298
|
+
{uses.length ? (
|
|
299
|
+
<ul>
|
|
300
|
+
{uses.map((u) => (
|
|
301
|
+
<li key={`${u.collection}/${u.path}`}>
|
|
302
|
+
<Icon name="page" size="sm" />
|
|
303
|
+
<span className="nm">{u.title}</span>
|
|
304
|
+
<span className="meta">{links(u.links)}</span>
|
|
305
|
+
</li>
|
|
306
|
+
))}
|
|
307
|
+
</ul>
|
|
308
|
+
) : null}
|
|
309
|
+
</div>
|
|
310
|
+
<p className="keeps">
|
|
311
|
+
<Icon name="clock" size="sm" />
|
|
312
|
+
<span>The old address keeps working.</span>
|
|
313
|
+
</p>
|
|
314
|
+
</>
|
|
315
|
+
);
|
|
316
|
+
if (phase.blocked)
|
|
317
|
+
note = (
|
|
318
|
+
<div className="note warn" role="alert" data-replace-blocked="">
|
|
319
|
+
<Icon name="warn" />
|
|
320
|
+
<div>
|
|
321
|
+
<b>{phase.blocked}</b>
|
|
322
|
+
<p>It starts once they have.</p>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
else if (phase.failed)
|
|
327
|
+
note = (
|
|
328
|
+
<div className="note danger" role="alert" data-replace-failed="">
|
|
329
|
+
<Icon name="warn" />
|
|
330
|
+
<div>
|
|
331
|
+
<b>Nothing was changed</b>
|
|
332
|
+
<p>{phase.failed} Try again.</p>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
action = (
|
|
337
|
+
<button type="button" className="btn ink" data-act="replace-go" disabled={!!phase.busy || !!phase.blocked} onClick={() => void go()}>
|
|
338
|
+
{phase.busy ? 'Replacing…' : phase.failed ? 'Try again' : total ? `Replace and update ${links(total)}` : 'Replace'}
|
|
339
|
+
</button>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<Frame label="replace">
|
|
345
|
+
{head}
|
|
346
|
+
{middle}
|
|
347
|
+
{note}
|
|
348
|
+
{picker}
|
|
349
|
+
<div className="acts">
|
|
350
|
+
{cancel}
|
|
351
|
+
{action}
|
|
352
|
+
</div>
|
|
353
|
+
</Frame>
|
|
354
|
+
);
|
|
355
|
+
}
|