caracms 0.8.0 → 0.8.2
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/README.md +23 -2
- package/package.json +1 -1
- package/skills/caracms-hosting/SKILL.md +1 -1
- package/src/body.ts +55 -0
- package/src/cli/validate.ts +1 -1
- package/src/cloud-middleware.ts +39 -3
- package/src/content.ts +15 -6
- package/src/document-problems.ts +48 -0
- package/src/editor/client/collections/CollectionGrid.tsx +2 -2
- package/src/editor/client/collections/CollectionTable.tsx +3 -3
- package/src/editor/client/documents/DocMenu.tsx +56 -1
- package/src/editor/client/documents/actions.ts +30 -1
- package/src/editor/client/documents/dialogs.tsx +61 -3
- package/src/editor/client/editor.ts +98 -2
- package/src/editor/client/fields/LockupField.tsx +4 -4
- package/src/editor/client/inbox/InboxStage.tsx +25 -1
- package/src/editor/client/live/notices.tsx +19 -0
- package/src/editor/client/live/publish.tsx +58 -2
- package/src/editor/client/nav/DocRow.tsx +4 -4
- package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
- package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
- package/src/editor/client/pane/parts.tsx +67 -4
- package/src/editor/client/pane/switcher.tsx +4 -4
- package/src/editor/client/shell/Bar.tsx +11 -0
- package/src/editor/client/state/keeper.ts +28 -4
- package/src/editor/client/state/live.ts +18 -1
- package/src/editor/routes/_live.ts +1 -1
- package/src/editor/routes/admin.astro +9 -0
- package/src/editor/routes/discard.ts +11 -0
- package/src/editor/server/admin-props.ts +18 -2
- package/src/editor/server/arrange.ts +4 -4
- package/src/editor/server/create.ts +4 -4
- package/src/editor/server/drafts-store.ts +2 -2
- package/src/editor/server/empty-site.ts +23 -0
- package/src/editor/server/media/cloud-mux.ts +4 -1
- package/src/editor/server/media/upload.ts +2 -41
- package/src/editor/server/move.ts +32 -4
- package/src/editor/server/write.ts +4 -4
- package/src/editor/styles/levels/live.css +2 -0
- package/src/editor/types.ts +92 -4
- package/src/forms/cloud-store.ts +4 -1
- package/src/forms/inbox.ts +10 -5
- package/src/forms/store.ts +14 -2
- package/src/index.ts +23 -8
- package/src/live/auth/login.ts +4 -3
- package/src/live/auth/views.ts +8 -1
- package/src/live/autosaves.ts +94 -6
- package/src/live/cloud.ts +64 -20
- package/src/live/drafts-store.ts +17 -6
- package/src/live/editor.ts +232 -16
- package/src/live/git.ts +492 -22
- package/src/live/github.ts +84 -17
- package/src/live/locks.ts +22 -0
- package/src/live/messages.ts +49 -2
- package/src/live/notices.ts +5 -1
- package/src/live/runtime.ts +2 -1
- package/src/live/status.ts +5 -0
- package/src/live/sweep.ts +44 -1
- package/src/live/webhook.ts +8 -3
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* editor.editHere() / useMine() / discardMine() / publish() / tryAgain() /
|
|
31
31
|
* dismissNotice(id) / signOut() / signInAgain()
|
|
32
32
|
*/
|
|
33
|
+
import { heldBackSentence } from '../../document-problems.ts';
|
|
33
34
|
import { ARRANGE_ADDRESS, addressOf, inboxAddress, inboxFromAddress, isArrangeAddress, refFromAddress } from '../addresses.ts';
|
|
34
35
|
import { heldBases } from '../../held.ts';
|
|
35
36
|
import {
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
type SubmissionsSetup,
|
|
53
54
|
type UiState,
|
|
54
55
|
type ViewMode,
|
|
56
|
+
type WaitingView,
|
|
55
57
|
type Width,
|
|
56
58
|
} from '../types.ts';
|
|
57
59
|
import type { MediaOnly } from '../../media-value.ts';
|
|
@@ -300,6 +302,13 @@ export interface Editor {
|
|
|
300
302
|
useMine(): Promise<void>;
|
|
301
303
|
/** Discard mine: drop them and show the document as the server has it. */
|
|
302
304
|
discardMine(): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* Discard changes (live mode): put one waiting document back to what's live. This tab's own
|
|
307
|
+
* unsent edits in it go first, and its held copy goes from this browser; an open document is
|
|
308
|
+
* read again (a new one closes, a moved one opens at its old address). The server's
|
|
309
|
+
* sentence when it refuses.
|
|
310
|
+
*/
|
|
311
|
+
discardChanges(item: Pick<WaitingView, 'file' | 'collection' | 'path' | 'title' | 'change'>): Promise<{ ok: true } | { ok: false; error: string }>;
|
|
303
312
|
/** Publish. `ok: false` with the reason when it didn't publish (the Publish popover says so; press again to retry). */
|
|
304
313
|
publish(): Promise<{ ok: boolean; error?: string }>;
|
|
305
314
|
tryAgain(): Promise<{ ok: boolean; error?: string }>;
|
|
@@ -409,6 +418,7 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
409
418
|
...(liveProps?.keepDelayMs !== undefined ? { keepDelay: liveProps.keepDelayMs } : {}),
|
|
410
419
|
...(liveProps?.keepCapMs !== undefined ? { keepCap: liveProps.keepCapMs } : {}),
|
|
411
420
|
onLocked: (file, refusal) => lockOverride.set({ file, state: refusal.state, lock: refusal.lock, after: liveSession.started() }),
|
|
421
|
+
onDiscarded: (file, body) => void letGo(file, body.by && body.at ? { by: body.by, at: body.at } : null),
|
|
412
422
|
onSignedOut: (body) => liveState.update({ signedOut: body }),
|
|
413
423
|
}
|
|
414
424
|
: {}),
|
|
@@ -587,6 +597,48 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
587
597
|
}
|
|
588
598
|
}
|
|
589
599
|
|
|
600
|
+
/** Wait until nothing for this document is in flight (at most a few seconds). */
|
|
601
|
+
async function settled(id: string): Promise<void> {
|
|
602
|
+
for (let i = 0; i < 100 && keeper?.dirty(id); i++) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Discard changes, from another tab's side: someone put this document back to what's live
|
|
607
|
+
* while this tab had it (the poll's `open.discarded`, or a write refused as `discarded`). This
|
|
608
|
+
* tab's copy is stale: its edits in it are dropped, the server is told it let go, and the
|
|
609
|
+
* document is read again (Home opens if it's gone: a new page discarded, or one moved back).
|
|
610
|
+
*/
|
|
611
|
+
const lettingGo = new Set<string>();
|
|
612
|
+
async function letGo(file: string, discarded: { by: Person; at: string } | null): Promise<void> {
|
|
613
|
+
if (!liveSession || !keeper) return;
|
|
614
|
+
const key = `${file}:${discarded?.at ?? ''}`;
|
|
615
|
+
if (lettingGo.has(key)) return;
|
|
616
|
+
lettingGo.add(key);
|
|
617
|
+
const open = doc.get().entry.file === file;
|
|
618
|
+
const id = open ? docId(doc.get().ref) : null;
|
|
619
|
+
const title = open ? doc.get().document.title || doc.get().entry.label : null;
|
|
620
|
+
if (id) {
|
|
621
|
+
keeper.discard(id);
|
|
622
|
+
await settled(id);
|
|
623
|
+
}
|
|
624
|
+
await liveSession.discardSeen(file);
|
|
625
|
+
if (open && doc.get().entry.file === file) {
|
|
626
|
+
let gone = false;
|
|
627
|
+
try {
|
|
628
|
+
await readDocument(doc.get().ref);
|
|
629
|
+
} catch {
|
|
630
|
+
gone = true;
|
|
631
|
+
}
|
|
632
|
+
if (gone) await editor.openDocument(site.home, { history: 'replace' });
|
|
633
|
+
else await refreshOpen();
|
|
634
|
+
}
|
|
635
|
+
if (title) {
|
|
636
|
+
const who = discarded ? displayName(discarded.by) : 'Someone';
|
|
637
|
+
editor.toast(`${who} discarded the unpublished changes to ${title}. It's back to what's live.`, 'check');
|
|
638
|
+
}
|
|
639
|
+
void liveSession.poll();
|
|
640
|
+
}
|
|
641
|
+
|
|
590
642
|
/** Held edits looked at again against the document as it is now (Decision 11). */
|
|
591
643
|
async function recheck(id: string): Promise<void> {
|
|
592
644
|
if (!keeper) return;
|
|
@@ -866,6 +918,38 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
866
918
|
editor.announce('Your held changes were discarded.');
|
|
867
919
|
},
|
|
868
920
|
|
|
921
|
+
async discardChanges(item) {
|
|
922
|
+
if (!liveSession) return { ok: false, error: 'Discarding changes needs live mode.' };
|
|
923
|
+
const id = item.collection && item.path ? docId({ collection: item.collection, path: item.path }) : null;
|
|
924
|
+
const openId = docId(doc.get().ref);
|
|
925
|
+
const isOpen = doc.get().entry.file === item.file || (id !== null && id === openId);
|
|
926
|
+
// This tab's own edits in it reach the server first, so nothing is in flight while it's discarded.
|
|
927
|
+
for (const one of new Set([id, isOpen ? openId : null])) {
|
|
928
|
+
if (!one) continue;
|
|
929
|
+
await keeper?.flush(one).catch(() => undefined);
|
|
930
|
+
await settled(one);
|
|
931
|
+
}
|
|
932
|
+
const answer = await liveSession.discard(item.file);
|
|
933
|
+
if (!answer.ok) return { ok: false, error: answer.error };
|
|
934
|
+
const back = answer.body.movedBack;
|
|
935
|
+
// This browser's held copies of it go too.
|
|
936
|
+
for (const one of new Set([id, isOpen ? openId : null, back ? docId(back) : null])) if (one) keeper?.discard(one);
|
|
937
|
+
await editor.refreshDocuments().catch(() => undefined);
|
|
938
|
+
if (isOpen) {
|
|
939
|
+
if (back) await editor.openDocument({ collection: back.collection, path: back.path }, { history: 'replace' });
|
|
940
|
+
else if (answer.body.change === 'new') {
|
|
941
|
+
await editor.openDocument(site.home, { history: 'push' });
|
|
942
|
+
const collection = site.collections.find((c) => c.name === item.collection);
|
|
943
|
+
if (collection && !collection.nested && collection.kind !== 'settings') editor.openCollection(collection.name);
|
|
944
|
+
else editor.goTo('documents');
|
|
945
|
+
} else await refreshOpen();
|
|
946
|
+
}
|
|
947
|
+
const said = answer.body.change === 'new' ? `Discarded ${item.title}.` : `Discarded the changes to ${item.title}. It's back to what's live.`;
|
|
948
|
+
editor.toast(said, 'check');
|
|
949
|
+
editor.announce(said);
|
|
950
|
+
return { ok: true };
|
|
951
|
+
},
|
|
952
|
+
|
|
869
953
|
async publish() {
|
|
870
954
|
if (!liveSession) return { ok: false };
|
|
871
955
|
await keeper?.flush();
|
|
@@ -874,8 +958,18 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
874
958
|
editor.announce("It didn't publish. Nothing went live.");
|
|
875
959
|
return { ok: false, error: answer.error };
|
|
876
960
|
}
|
|
877
|
-
|
|
878
|
-
|
|
961
|
+
// caracms-cloud adversarial review, finding 15: what was left waiting, and why, in the toast.
|
|
962
|
+
const held = (answer.body.held ?? []).map((h) => heldBackSentence(h.title, h.reason)).join(' ');
|
|
963
|
+
if (answer.body.result === 'held-back') {
|
|
964
|
+
const said = `Nothing was published. ${held}`;
|
|
965
|
+
editor.toast(said, 'warn');
|
|
966
|
+
editor.announce(said);
|
|
967
|
+
} else if (answer.body.result === 'nothing-waiting') editor.announce('Nothing to publish.');
|
|
968
|
+
else if (held) {
|
|
969
|
+
const said = `Publishing the rest. ${held}`;
|
|
970
|
+
editor.toast(said, 'warn');
|
|
971
|
+
editor.announce(said);
|
|
972
|
+
} else editor.announce('Publishing.');
|
|
879
973
|
return { ok: true };
|
|
880
974
|
},
|
|
881
975
|
|
|
@@ -964,6 +1058,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
964
1058
|
const seen = seenChange;
|
|
965
1059
|
seenChange = change;
|
|
966
1060
|
if (access.get().veil && change && change !== seen && change !== loadedVersion && !keeper.dirty(openId)) void refreshOpen();
|
|
1061
|
+
// Discard changes: someone put it back to what's live while this tab had it.
|
|
1062
|
+
if (open.discarded) void letGo(open.file, open.discarded);
|
|
967
1063
|
}),
|
|
968
1064
|
);
|
|
969
1065
|
}
|
|
@@ -208,7 +208,8 @@ export function LockupField(props: { blockKey: string; index: number; name: stri
|
|
|
208
208
|
</span>
|
|
209
209
|
) : null
|
|
210
210
|
}
|
|
211
|
-
|
|
211
|
+
// Editor wording note (caracms-cloud adversarial review): "Lockup" is the package's word, not an editor's.
|
|
212
|
+
meta={<span className="field-meta">Heading style: {styleName}</span>}
|
|
212
213
|
>
|
|
213
214
|
{onPhone ? (
|
|
214
215
|
<div className="mini-seg" role="radiogroup" aria-label="Which lines to edit">
|
|
@@ -291,9 +292,8 @@ export function LockupField(props: { blockKey: string; index: number; name: stri
|
|
|
291
292
|
<Icon name="warn" />
|
|
292
293
|
<div>
|
|
293
294
|
<b>Too long for mobile: shorten a line or split it</b>
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
</p>
|
|
295
|
+
{/* Editor wording note: what happens, not the sizes. A line never wraps; the heading shrinks to fit its longest line. */}
|
|
296
|
+
<p>On a phone, the heading shrinks so “{longest}” fits on one line, and comes out smaller than this heading style is meant to go.</p>
|
|
297
297
|
{hasPhone ? null : (
|
|
298
298
|
<div className="acts">
|
|
299
299
|
{!onPhone ? (
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* **Reply by email** (a `mailto:` with the address percent-encoded and the subject
|
|
8
8
|
* "Re: your enquiry to <brand>"), **Mark unread** (which closes it: staying open would be
|
|
9
9
|
* reading it again), **Delete…** (after a confirm: documents/dialogs.tsx); then the email's
|
|
10
|
-
* outcome (sent to whom, failed with Resend's message, skipped and why,
|
|
10
|
+
* outcome (sent to whom, failed with Resend's message, skipped and why, held behind a cloud
|
|
11
|
+
* site's daily limit with the cloud's sentence, or unknown), and
|
|
11
12
|
* every field by its label, files as downloads with their sizes.
|
|
12
13
|
*
|
|
13
14
|
* **Visitor strings are only ever text** (React's escaping): no `dangerouslySetInnerHTML`
|
|
@@ -157,6 +158,16 @@ function Sheet({ answer }: { answer: SubmissionResponse }) {
|
|
|
157
158
|
))}
|
|
158
159
|
</div>
|
|
159
160
|
</dd>
|
|
161
|
+
) : submission.filesNote ? (
|
|
162
|
+
// The cloud's attachment limit: the files weren't kept. Said plainly, not as an error; the sentence is the cloud's.
|
|
163
|
+
<dd key={`${f.name}:d`}>
|
|
164
|
+
<div className="note" data-files="not-kept">
|
|
165
|
+
<Icon name="attach" />
|
|
166
|
+
<div>
|
|
167
|
+
<p>{submission.filesNote}</p>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</dd>
|
|
160
171
|
) : (
|
|
161
172
|
<dd key={`${f.name}:d`} className="none">
|
|
162
173
|
No files
|
|
@@ -242,6 +253,19 @@ function MailOutcome({ email, formLabel, timeZone, editor }: { email: EmailOutco
|
|
|
242
253
|
</div>
|
|
243
254
|
);
|
|
244
255
|
}
|
|
256
|
+
if (email.status === 'held') {
|
|
257
|
+
// Finding 20 (caracms-cloud adversarial review): the cloud holds it behind the site's daily
|
|
258
|
+
// limit; it goes out on its own, so it's calm, not a problem. The sentence is the cloud's.
|
|
259
|
+
return (
|
|
260
|
+
<div className="note" data-mail="held">
|
|
261
|
+
<Icon name="clock" />
|
|
262
|
+
<div>
|
|
263
|
+
<b>Email held</b>
|
|
264
|
+
<p>{email.error ?? 'The email about it is waiting to go out.'}</p>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
245
269
|
// Skipped: no one to email (Site Settings sets who), or not the production server (which alone sends email).
|
|
246
270
|
const notProduction = (email.error ?? '').startsWith('Not emailed');
|
|
247
271
|
return (
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* lock) and **Discard mine**.
|
|
12
12
|
* - **The merge notice** (`#merge`), until dismissed or published.
|
|
13
13
|
* - **"Aoife published Home as it stood at 14:20"** (`#published-over`).
|
|
14
|
+
* - **"Aoife discarded the unpublished changes to About"** (Discard changes), with OK.
|
|
14
15
|
* - **Someone deleted it** (M5a Decision 12: the poll's `open.deleted`): who and when,
|
|
15
16
|
* read-only, with a way back to the documents.
|
|
16
17
|
*
|
|
@@ -172,6 +173,24 @@ function Notice({ notice, timeZone }: { notice: NoticeView; timeZone: string })
|
|
|
172
173
|
</div>
|
|
173
174
|
);
|
|
174
175
|
}
|
|
176
|
+
if (notice.kind === 'discarded') {
|
|
177
|
+
return (
|
|
178
|
+
<div className="note select" role="status" data-note="discarded">
|
|
179
|
+
<Icon name="undo" />
|
|
180
|
+
<div>
|
|
181
|
+
<b>
|
|
182
|
+
{displayName(notice.by)} discarded the unpublished changes to {notice.title} at {clock(notice.at, timeZone)}
|
|
183
|
+
</b>
|
|
184
|
+
<p>It's back to what's live.</p>
|
|
185
|
+
<div className="acts">
|
|
186
|
+
<button type="button" className="btn sm" data-act="dismiss" onClick={() => editor.dismissNotice(notice.id)}>
|
|
187
|
+
OK
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
175
194
|
return (
|
|
176
195
|
<div className="note select" role="status" data-note="published-over">
|
|
177
196
|
<Icon name="check" />
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
* document on that field (a list row expanded, the alt input focused; a document
|
|
26
26
|
* someone else holds opens read-only with the field shown). The bar's count gets a
|
|
27
27
|
* warning dot. **Publish stays on and one click**: nothing here holds it back.
|
|
28
|
+
* - **Held back** (caracms-cloud adversarial review, finding 15): a waiting document the
|
|
29
|
+
* reader would refuse (no title: `WaitingView.heldBack`) says so on its row ("Tinder won't
|
|
30
|
+
* be published: its title is empty."), with Add a title, which opens it. Publish sends the
|
|
31
|
+
* rest ("Publish 1 change"); with nothing else waiting it's off, and says why. What was
|
|
32
|
+
* held back is said again in the toast when Publish answers (editor.ts → publish).
|
|
33
|
+
* - **Discard…** on each waiting document (Discard changes): the confirmation
|
|
34
|
+
* (documents/dialogs.tsx → `discard`) puts it back to what's live.
|
|
28
35
|
*/
|
|
29
36
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
30
37
|
import { useEditor, useStore } from '../context.tsx';
|
|
@@ -37,6 +44,8 @@ import { clock, displayName, joinNames, samePerson, when, zoneLine } from './for
|
|
|
37
44
|
import { Avatar } from './marks.tsx';
|
|
38
45
|
import { docIcon } from '../nav/DocRow.tsx';
|
|
39
46
|
import { singular } from '../documents/actions.ts';
|
|
47
|
+
import { openDialog } from '../documents/dialogs.tsx';
|
|
48
|
+
import { NO_TITLE } from '../../../document-problems.ts';
|
|
40
49
|
|
|
41
50
|
const histories = new WeakMap<Editor, { entries: HistoryEntryView[]; timeZone: string } | null>();
|
|
42
51
|
|
|
@@ -221,6 +230,8 @@ export function WaitingPop() {
|
|
|
221
230
|
</div>
|
|
222
231
|
);
|
|
223
232
|
const n = status.waiting.length;
|
|
233
|
+
// Finding 15: what Publish will send; the rest stays waiting.
|
|
234
|
+
const going = status.waiting.filter((w) => !w.heldBack).length;
|
|
224
235
|
const pub = status.publish;
|
|
225
236
|
const record = pub.record;
|
|
226
237
|
const names = joinNames(record?.titles ?? []);
|
|
@@ -399,6 +410,11 @@ export function WaitingPop() {
|
|
|
399
410
|
<WaitItem key={w.file} item={w} status={status} />
|
|
400
411
|
))}
|
|
401
412
|
</ul>
|
|
413
|
+
{going === 0 ? (
|
|
414
|
+
<p className="field-help" data-note="all-held">
|
|
415
|
+
Nothing can be published until {n === 1 ? 'it has' : 'they have'} what's missing.
|
|
416
|
+
</p>
|
|
417
|
+
) : null}
|
|
402
418
|
{publishError ? (
|
|
403
419
|
<div className="note danger tight" role="alert" data-note="publish-failed">
|
|
404
420
|
<Icon name="warn" />
|
|
@@ -416,7 +432,7 @@ export function WaitingPop() {
|
|
|
416
432
|
type="button"
|
|
417
433
|
className="btn lg accent"
|
|
418
434
|
data-act="publish"
|
|
419
|
-
disabled={busy}
|
|
435
|
+
disabled={busy || going === 0}
|
|
420
436
|
onClick={async () => {
|
|
421
437
|
setBusy(true);
|
|
422
438
|
setPublishError(null);
|
|
@@ -425,7 +441,7 @@ export function WaitingPop() {
|
|
|
425
441
|
if (!r.ok) setPublishError(r.error ?? 'the server said no.');
|
|
426
442
|
}}
|
|
427
443
|
>
|
|
428
|
-
Publish {
|
|
444
|
+
{going ? `Publish ${going} change${going === 1 ? '' : 's'}` : 'Publish'}
|
|
429
445
|
</button>
|
|
430
446
|
</div>
|
|
431
447
|
</>,
|
|
@@ -509,11 +525,51 @@ function WaitItem({ item, status }: { item: WaitingView; status: StatusResponse
|
|
|
509
525
|
</div>
|
|
510
526
|
</div>
|
|
511
527
|
) : null}
|
|
528
|
+
{item.heldBack ? <HeldBackNote item={item} /> : null}
|
|
512
529
|
{item.change !== 'deleted' && item.missingAlt?.length ? <AltGaps item={item} held={others} /> : null}
|
|
530
|
+
<div className="wi-acts">
|
|
531
|
+
<button type="button" className="linkbtn subtle" data-act="discard" aria-label={`Discard the unpublished changes to ${item.title}`} onClick={() => openDialog(editor, { kind: 'discard', file: item.file })}>
|
|
532
|
+
Discard…
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
513
535
|
</li>
|
|
514
536
|
);
|
|
515
537
|
}
|
|
516
538
|
|
|
539
|
+
/** Finding 15: a waiting document Publish will leave waiting, and why, with the way to fix it. */
|
|
540
|
+
function HeldBackNote({ item }: { item: WaitingView }) {
|
|
541
|
+
const editor = useEditor();
|
|
542
|
+
const untitled = item.heldBack === NO_TITLE.reason;
|
|
543
|
+
const open = async () => {
|
|
544
|
+
if (item.collection === null || item.path === null) return;
|
|
545
|
+
editor.setPop(null);
|
|
546
|
+
await editor.openDocument({ collection: item.collection, path: item.path });
|
|
547
|
+
// A form's title is its first field; a page or a shared block is renamed.
|
|
548
|
+
requestAnimationFrame(() => {
|
|
549
|
+
const input = document.querySelector<HTMLInputElement>('#side .side-level:not([hidden]) input[data-path="title"]');
|
|
550
|
+
if (input && !input.disabled) input.focus();
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
return (
|
|
554
|
+
<div className="note warn tight" data-note="held-back">
|
|
555
|
+
<Icon name="warn" />
|
|
556
|
+
<div>
|
|
557
|
+
<b>
|
|
558
|
+
{item.title} won't be published: {item.heldBack}.
|
|
559
|
+
</b>
|
|
560
|
+
<p>It stays waiting. Publish sends the rest.</p>
|
|
561
|
+
{untitled && item.collection !== null ? (
|
|
562
|
+
<div className="acts">
|
|
563
|
+
<button type="button" className="btn sm" data-act="add-title" onClick={() => void open()}>
|
|
564
|
+
Add a title
|
|
565
|
+
</button>
|
|
566
|
+
</div>
|
|
567
|
+
) : null}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
517
573
|
/** One waiting document's images without alt text (#publish-alt), each with Add alt text. It doesn't hold the Publish back. */
|
|
518
574
|
function AltGaps({ item, held }: { item: WaitingView; held: Person[] }) {
|
|
519
575
|
const editor = useEditor();
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { useState, type CSSProperties, type ReactNode } from 'react';
|
|
15
15
|
import { useEditor, useStore } from '../context.tsx';
|
|
16
16
|
import { Icon } from '../shell/Icons.tsx';
|
|
17
|
-
import { docId, sameDoc, type ListedDocument } from '../../types.ts';
|
|
17
|
+
import { docId, opensInEditor, sameDoc, warnedWords, type ListedDocument } from '../../types.ts';
|
|
18
18
|
import { DocMarksView } from '../live/marks.tsx';
|
|
19
19
|
|
|
20
20
|
/** A document's icon by its collection: a page, a case study (any list-of-fields document), a shared block, Site Settings, else a post. */
|
|
@@ -56,7 +56,7 @@ export function DocRow(props: {
|
|
|
56
56
|
const collection = editor.site.collections.find((c) => c.name === doc.collection);
|
|
57
57
|
const classes = ['drow', props.className, current ? 'is-current' : '', doc.warning ? 'is-warned' : '', props.active ? 'is-active' : ''].filter(Boolean).join(' ');
|
|
58
58
|
const open = () => {
|
|
59
|
-
if (doc.warning) {
|
|
59
|
+
if (doc.warning && !opensInEditor(doc)) {
|
|
60
60
|
setWhy((w) => !w);
|
|
61
61
|
editor.announce(`${doc.label} can't be opened. ${doc.warning.message}`);
|
|
62
62
|
return;
|
|
@@ -73,7 +73,7 @@ export function DocRow(props: {
|
|
|
73
73
|
style={props.style}
|
|
74
74
|
data-doc={id}
|
|
75
75
|
aria-current={current ? 'page' : undefined}
|
|
76
|
-
aria-expanded={doc.warning ? why : undefined}
|
|
76
|
+
aria-expanded={doc.warning && !opensInEditor(doc) ? why : undefined}
|
|
77
77
|
title={doc.warning?.message}
|
|
78
78
|
onClick={open}
|
|
79
79
|
>
|
|
@@ -86,7 +86,7 @@ export function DocRow(props: {
|
|
|
86
86
|
{doc.warning ? (
|
|
87
87
|
<span className="wmark" data-warning={doc.warning.code}>
|
|
88
88
|
<Icon name="warn" size="xs" />
|
|
89
|
-
<span className="sr"
|
|
89
|
+
<span className="sr">{warnedWords(doc)}</span>
|
|
90
90
|
</span>
|
|
91
91
|
) : null}
|
|
92
92
|
{/* M9 Decision 7: hidden from the site (or under a hidden page). */}
|
|
@@ -23,6 +23,7 @@ import { CurrentHead, DocumentsTab, GlobalNotes, RecentStrip, Trail } from '../p
|
|
|
23
23
|
import { usedOnLine } from '../../block-list/summary.ts';
|
|
24
24
|
import { PAGELESS_SUB, singular } from '../../documents/actions.ts';
|
|
25
25
|
import { openDialog } from '../../documents/dialogs.tsx';
|
|
26
|
+
import { opensInEditor } from '../../../types.ts';
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
export function CollectionLevel() {
|
|
@@ -88,7 +89,7 @@ export function CollectionLevel() {
|
|
|
88
89
|
onChange={(e) => setQuery(e.target.value)}
|
|
89
90
|
onKeyDown={(e) => {
|
|
90
91
|
if (e.key === 'Enter') {
|
|
91
|
-
const first = shown.find((d) =>
|
|
92
|
+
const first = shown.find((d) => opensInEditor(d));
|
|
92
93
|
if (first) void editor.openDocument({ collection: first.collection, path: first.path });
|
|
93
94
|
} else if (e.key === 'Escape' && query) {
|
|
94
95
|
e.preventDefault();
|
|
@@ -17,7 +17,7 @@ import { openPicker, pickerAt } from '../../block-list/link.ts';
|
|
|
17
17
|
import { Picker } from '../../block-list/Picker.tsx';
|
|
18
18
|
import { CurrentHead, DocumentsTab, GlobalNotes, parentLabel, RecentStrip, Trail } from '../parts.tsx';
|
|
19
19
|
import { DocNotices, ReadOnlyChip } from '../../live/notices.tsx';
|
|
20
|
-
import { DocMenu } from '../../documents/DocMenu.tsx';
|
|
20
|
+
import { DocMenu, TitleNote } from '../../documents/DocMenu.tsx';
|
|
21
21
|
import { PagesUnder } from '../../documents/PagesUnder.tsx';
|
|
22
22
|
import { FieldsDocumentLevel, SettingsLevel, SharedDocumentLevel } from './FormLevel.tsx';
|
|
23
23
|
import { SeoGroup } from '../../fields/SeoGroup.tsx';
|
|
@@ -71,6 +71,7 @@ function SectionsLevel() {
|
|
|
71
71
|
<div className="side-body" data-document={`${ref.collection}/${ref.path}`}>
|
|
72
72
|
<GlobalNotes />
|
|
73
73
|
<DocNotices />
|
|
74
|
+
<TitleNote />
|
|
74
75
|
{empty ? (
|
|
75
76
|
<div className="empty" data-empty="page" style={{ padding: 'var(--space-4)' }}>
|
|
76
77
|
<b>No blocks yet</b>
|
|
@@ -36,7 +36,7 @@ import { Bubble } from '../../live/marks.tsx';
|
|
|
36
36
|
import { searchDocuments } from '../../nav/search.ts';
|
|
37
37
|
import { treeRows, type TreeRow } from '../../nav/tree.ts';
|
|
38
38
|
import { arrangeSite } from '../../arrange/store.ts';
|
|
39
|
-
import type
|
|
39
|
+
import { opensInEditor, type EditorCollection, type ListedDocument } from '../../../types.ts';
|
|
40
40
|
import { CurrentHead, GlobalNotes, RecentStrip, Trail } from '../parts.tsx';
|
|
41
41
|
|
|
42
42
|
export function DocumentsLevel() {
|
|
@@ -59,7 +59,7 @@ export function DocumentsLevel() {
|
|
|
59
59
|
if (event.key === 'Enter') {
|
|
60
60
|
const first = searchDocuments(query, documents, editor.site.collections)
|
|
61
61
|
.flatMap((g) => g.hits)
|
|
62
|
-
.find((h) =>
|
|
62
|
+
.find((h) => opensInEditor(h.doc));
|
|
63
63
|
if (first) void editor.openDocument({ collection: first.doc.collection, path: first.doc.path });
|
|
64
64
|
} else if (event.key === 'Escape' && query) {
|
|
65
65
|
// Clear the box first; a second Esc leaves it (App.tsx).
|
|
@@ -28,9 +28,11 @@ import { parseMedia } from '../../../../media-value.ts';
|
|
|
28
28
|
import { parseAllowEntry } from '../../../../live/auth/allow-rules.ts';
|
|
29
29
|
import { useEditor, useStore } from '../../context.tsx';
|
|
30
30
|
import type { Editor } from '../../editor.ts';
|
|
31
|
-
import { DocMenu } from '../../documents/DocMenu.tsx';
|
|
31
|
+
import { DocMenu, TitleNote } from '../../documents/DocMenu.tsx';
|
|
32
32
|
import { FieldControl } from '../../fields/FieldControl.tsx';
|
|
33
33
|
import { FieldFrame } from '../../fields/FieldFrame.tsx';
|
|
34
|
+
import { Warn } from '../../fields/parts.tsx';
|
|
35
|
+
import { NO_TITLE } from '../../../../document-problems.ts';
|
|
34
36
|
import { FieldHostProvider, parseBase, type FieldHost } from '../../fields/host.tsx';
|
|
35
37
|
import { MediaSlot } from '../../fields/MediaField.tsx';
|
|
36
38
|
import { mediaFor } from '../../media/store.ts';
|
|
@@ -102,14 +104,38 @@ export function CollectionTab({ name }: { name: string }) {
|
|
|
102
104
|
return <LevelTab name={collection.label} kind="Collection" count={String(count)} onClick={() => editor.openCollection(name)} />;
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Editor wording note (caracms-cloud adversarial review): beside a heading (a lockup field),
|
|
109
|
+
* the title says where it shows, so the two aren't mistaken for each other. The document's
|
|
110
|
+
* title is its name in the site's lists and links, and the browser tab's and search
|
|
111
|
+
* results' title unless its SEO title says otherwise (head.ts).
|
|
112
|
+
*/
|
|
113
|
+
export const TITLE_BESIDE_HEADING = 'Shown in lists, links, browser tabs and search results.';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The title field every fields document has first (Decision 7): one line, always there.
|
|
117
|
+
* Blank, it says so (caracms-cloud adversarial review, finding 15), as typed: the edit is
|
|
118
|
+
* kept all the same, and Publish leaves the document waiting until it has one.
|
|
119
|
+
*/
|
|
120
|
+
function TitleField({ scope, help }: { scope: string; help?: string }) {
|
|
107
121
|
const editor = useEditor();
|
|
108
122
|
const title = useStore(editor.doc.state, (s) => s.document.title);
|
|
109
123
|
const id = `f-${scope}-title`;
|
|
124
|
+
const blank = title.trim() === '';
|
|
110
125
|
return (
|
|
111
|
-
<FieldFrame blockKey={scope} name="title" label="Title" htmlFor={id}>
|
|
112
|
-
<input
|
|
126
|
+
<FieldFrame blockKey={scope} name="title" label="Title" htmlFor={id} help={help}>
|
|
127
|
+
<input
|
|
128
|
+
className="input"
|
|
129
|
+
id={id}
|
|
130
|
+
data-path="title"
|
|
131
|
+
value={title}
|
|
132
|
+
autoComplete="off"
|
|
133
|
+
spellCheck={false}
|
|
134
|
+
aria-invalid={blank || undefined}
|
|
135
|
+
aria-describedby={blank ? `${id}-problem` : undefined}
|
|
136
|
+
onChange={(e) => editor.doc.setTitle(e.target.value.replace(/\n+/g, ' '))}
|
|
137
|
+
/>
|
|
138
|
+
{blank ? <Warn id={`${id}-problem`}>{NO_TITLE.note}</Warn> : null}
|
|
113
139
|
</FieldFrame>
|
|
114
140
|
);
|
|
115
141
|
}
|
|
@@ -188,7 +214,7 @@ export function FieldsDocumentLevel() {
|
|
|
188
214
|
<DocNotices />
|
|
189
215
|
<FormFields readOnly={readOnly}>
|
|
190
216
|
<FieldHostProvider host={host}>
|
|
191
|
-
<TitleField scope={key} />
|
|
217
|
+
<TitleField scope={key} help={Object.values(fields).some((f) => f.kind === 'lockup') ? TITLE_BESIDE_HEADING : undefined} />
|
|
192
218
|
{formRows(fields).map((row) => {
|
|
193
219
|
const control = ([name, field]: [string, Field]) => (
|
|
194
220
|
<FieldControl key={`${key}:${name}`} name={name} label={field.label ?? titleCase(name)} field={field} value={data[name]} lockupIndex={0} />
|
|
@@ -257,6 +283,7 @@ export function SharedDocumentLevel() {
|
|
|
257
283
|
<div className="side-body" data-document={docId(ref)}>
|
|
258
284
|
<GlobalNotes />
|
|
259
285
|
<DocNotices />
|
|
286
|
+
<TitleNote />
|
|
260
287
|
{n ? (
|
|
261
288
|
<div className="note select" data-note="used-on">
|
|
262
289
|
<Icon name="shared" />
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - **The list**, newest first, 50 at a time with Older (by cursor). A row: the unread
|
|
6
6
|
* mark (a dot and a heavier name, never a side rail), who sent it, when (the site's
|
|
7
7
|
* zone: "14:12" today, then "Yesterday" or "Tue 13 Oct"), a one-line snippet, the
|
|
8
|
-
* form, the file count,
|
|
8
|
+
* form, the file count, **Email held** (quiet, beside the file count: a cloud site's
|
|
9
|
+
* daily limit; finding 20) and **Email failed** when its email failed. The open one has
|
|
9
10
|
* the selection's wash and ring.
|
|
10
11
|
* - **A row opens the submission** on a sheet over the dimmed page (inbox/InboxStage.tsx),
|
|
11
12
|
* with the list kept here; the address follows (`/admin/submissions/<id>`).
|
|
@@ -113,8 +114,12 @@ const settingsPath = (editor: ReturnType<typeof useEditor>) => editor.documents.
|
|
|
113
114
|
function Row({ row, current, timeZone, onOpen }: { row: SubmissionRow; current: boolean; timeZone: string; onOpen: () => void }) {
|
|
114
115
|
const unread = row.readAt === null;
|
|
115
116
|
const failed = row.emailStatus === 'failed';
|
|
117
|
+
// Finding 20: a held email is said quietly, beside the files, never as a failure.
|
|
118
|
+
const held = row.emailStatus === 'held';
|
|
119
|
+
// The cloud's attachment limit: said as quietly as a held email.
|
|
120
|
+
const notKept = row.filesNotKept === true;
|
|
116
121
|
const time = rowTime(row.receivedAt, timeZone);
|
|
117
|
-
const label = [row.name, `${row.formLabel} form`, time, unread ? 'unread' : '', row.files ? filesLabel(row.files) : '', failed ? 'email failed' : ''].filter(Boolean).join(', ');
|
|
122
|
+
const label = [row.name, `${row.formLabel} form`, time, unread ? 'unread' : '', row.files ? filesLabel(row.files) : '', failed ? 'email failed' : '', held ? 'email held' : '', notKept ? 'attachments not kept' : ''].filter(Boolean).join(', ');
|
|
118
123
|
return (
|
|
119
124
|
<button
|
|
120
125
|
type="button"
|
|
@@ -139,6 +144,18 @@ function Row({ row, current, timeZone, onOpen }: { row: SubmissionRow; current:
|
|
|
139
144
|
{filesLabel(row.files)}
|
|
140
145
|
</span>
|
|
141
146
|
) : null}
|
|
147
|
+
{notKept ? (
|
|
148
|
+
<span data-files="not-kept">
|
|
149
|
+
<Icon name="attach" />
|
|
150
|
+
Attachments not kept
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
153
|
+
{held ? (
|
|
154
|
+
<span data-mail="held">
|
|
155
|
+
<Icon name="clock" />
|
|
156
|
+
Email held
|
|
157
|
+
</span>
|
|
158
|
+
) : null}
|
|
142
159
|
{failed ? (
|
|
143
160
|
<span className="chip danger">
|
|
144
161
|
<Icon name="warn" size="xs" />
|