caracms 0.4.0 → 0.5.1
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 +14 -0
- package/package.json +1 -1
- package/src/cli/validate.ts +53 -3
- package/src/content-schema.ts +1 -0
- package/src/content.ts +41 -20
- package/src/document.ts +5 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/documents/actions.ts +14 -3
- package/src/editor/client/documents/dialogs.tsx +33 -3
- package/src/editor/client/editor.ts +56 -5
- package/src/editor/client/pane/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +10 -0
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/server/admin-props.ts +5 -3
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/drafts-store.ts +34 -4
- package/src/editor/server/move.ts +41 -7
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/types.ts +52 -2
- package/src/index.ts +45 -3
- package/src/live/drafts-store.ts +62 -4
- package/src/live/editor.ts +4 -0
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +39 -7
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +17 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrange pages (Contracts → Apply request): the arrange screen's Apply, both modes.
|
|
3
|
+
*
|
|
4
|
+
* **A security boundary, as move is** (move.ts): the paths come from the browser, so the
|
|
5
|
+
* route is the same POST-JSON-same-origin handler, and every move goes through the
|
|
6
|
+
* many-move engine (`moveMany`: create.ts → `target`, the store's own checks and locks).
|
|
7
|
+
*
|
|
8
|
+
* - `planArrange` expands each root with Change address…'s expansion (move.ts →
|
|
9
|
+
* `movesUnder`): everything under it, except what's under another root, which wins for its
|
|
10
|
+
* own subtree. It checks every `order` key and slug against the Pages collection as it
|
|
11
|
+
* will be once the moves land, and builds the engine's Site Settings edit: the saved order
|
|
12
|
+
* re-keyed after the moves, the request's lists replacing those keys (page-order.ts →
|
|
13
|
+
* `arrangedOrder`), so the order and the redirects land, or roll back, in one write.
|
|
14
|
+
* - With no moves (an order-only Apply, Decision 21), the store writes Site Settings alone,
|
|
15
|
+
* taking its lock in live mode (`DraftsStore.arrange`).
|
|
16
|
+
* - Change address… keeps the order current the same way (`orderEdit`, Decision 6).
|
|
17
|
+
*/
|
|
18
|
+
import type { ResolvedCollection, ResolvedSite } from '../../config.ts';
|
|
19
|
+
import type { DocumentEntry } from '../../content.ts';
|
|
20
|
+
import { parseAnyDocument, type SettingsDocument } from '../../document.ts';
|
|
21
|
+
import type { Person } from '../../live/messages.ts';
|
|
22
|
+
import { arrangedOrder, isOrderedCollection, listingStamp, orderAfterMoves, parsePageOrder, type PageOrder, type PathChange } from '../../page-order.ts';
|
|
23
|
+
import { applyKeyedEdits } from '../../serialize.ts';
|
|
24
|
+
import type { ArrangeRequest, ArrangeResponse } from '../types.ts';
|
|
25
|
+
import { refused } from './create.ts';
|
|
26
|
+
import { movesUnder, type PathMove } from './move.ts';
|
|
27
|
+
import { checkOrigin, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
|
|
28
|
+
|
|
29
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
30
|
+
|
|
31
|
+
/** An Apply's body can carry a whole tree's order and moves. */
|
|
32
|
+
export const MAX_ARRANGE_BODY_BYTES = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
const ARRANGE_SHAPE = '{ moves: [{ collection, fromPath, toPath }…], order: { "<parent path>": ["slug"…] }, redirect?, expect? }';
|
|
35
|
+
|
|
36
|
+
/** Decision 10's sentence when the listing isn't the one the review was planned on. */
|
|
37
|
+
export const CHANGED_MESSAGE = 'The pages changed since you opened the review.';
|
|
38
|
+
|
|
39
|
+
/** Validate an Apply body's shape. Throws WriteRefused 400. */
|
|
40
|
+
export function parseArrangeBody(body: unknown): ArrangeRequest {
|
|
41
|
+
const bad = (why: string) => refused(400, 'bad-request', `Malformed body: ${why}. Send ${ARRANGE_SHAPE}.`);
|
|
42
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw bad('it must be a JSON object');
|
|
43
|
+
const b = body as Record<string, unknown>;
|
|
44
|
+
const extra = Object.keys(b).filter((k) => !['moves', 'order', 'redirect', 'expect'].includes(k));
|
|
45
|
+
if (extra.length) throw bad(`unexpected ${extra.map((k) => JSON.stringify(k.slice(0, 60))).join(', ')}`);
|
|
46
|
+
if (!Array.isArray(b.moves)) throw bad('"moves" must be a list');
|
|
47
|
+
const moves = b.moves.map((m, i) => {
|
|
48
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) throw bad(`moves[${i}] must be { collection, fromPath, toPath }`);
|
|
49
|
+
const r = m as Record<string, unknown>;
|
|
50
|
+
if (Object.keys(r).some((k) => !['collection', 'fromPath', 'toPath'].includes(k))) throw bad(`moves[${i}] must be { collection, fromPath, toPath }`);
|
|
51
|
+
if (typeof r.collection !== 'string' || r.collection === '') throw bad(`moves[${i}].collection must be a collection's name`);
|
|
52
|
+
if (typeof r.fromPath !== 'string' || r.fromPath === '') throw bad(`moves[${i}].fromPath must be the page's path`);
|
|
53
|
+
if (typeof r.toPath !== 'string') throw bad(`moves[${i}].toPath must be the page's new path`);
|
|
54
|
+
if (r.fromPath.length > 512 || r.toPath.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
|
|
55
|
+
return { collection: r.collection, fromPath: r.fromPath, toPath: r.toPath };
|
|
56
|
+
});
|
|
57
|
+
if (!b.order || typeof b.order !== 'object' || Array.isArray(b.order)) throw bad('"order" must be an object of lists');
|
|
58
|
+
const order: PageOrder = {};
|
|
59
|
+
for (const [key, list] of Object.entries(b.order as Record<string, unknown>)) {
|
|
60
|
+
if (!Array.isArray(list) || !list.every((s) => typeof s === 'string' && s !== '')) throw bad(`order[${JSON.stringify(key.slice(0, 60))}] must be a list of slugs`);
|
|
61
|
+
if (key.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
|
|
62
|
+
order[key] = [...new Set(list as string[])];
|
|
63
|
+
}
|
|
64
|
+
if (b.redirect !== undefined && typeof b.redirect !== 'boolean') throw bad('"redirect" must be true or false');
|
|
65
|
+
let expect: Record<string, string> | undefined;
|
|
66
|
+
if (b.expect !== undefined) {
|
|
67
|
+
if (!b.expect || typeof b.expect !== 'object' || Array.isArray(b.expect) || !Object.values(b.expect).every((v) => typeof v === 'string')) throw bad('"expect" must be { "<collection>": "<stamp>" }');
|
|
68
|
+
expect = { ...(b.expect as Record<string, string>) };
|
|
69
|
+
}
|
|
70
|
+
return { moves, order, ...(b.redirect === false ? { redirect: false } : {}), ...(expect ? { expect } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const orderedCollection = (site: Pick<Site, 'collections'>): ResolvedCollection | undefined => Object.values(site.collections).find((c) => isOrderedCollection(c));
|
|
74
|
+
const settingsCollection = (site: Pick<Site, 'collections'>): ResolvedCollection | undefined => Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
75
|
+
|
|
76
|
+
/** Site Settings' `pageOrder` in its text (`{}`: none, or the text isn't JSON). */
|
|
77
|
+
export function orderIn(text: string | null): PageOrder {
|
|
78
|
+
if (text === null) return {};
|
|
79
|
+
try {
|
|
80
|
+
const raw = JSON.parse(text) as unknown;
|
|
81
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? parsePageOrder((raw as Record<string, unknown>).pageOrder) : {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Site Settings' text with `pageOrder` set to `order` (taken out when it's empty), every
|
|
89
|
+
* other key as it was; `text` null: a new file. Unchanged text when the order is the same,
|
|
90
|
+
* or when the text isn't JSON (the editor warns about that file).
|
|
91
|
+
*/
|
|
92
|
+
export function settingsWithOrder(site: Pick<Site, 'collections' | 'blocks'>, text: string | null, order: PageOrder): string | null {
|
|
93
|
+
const collection = settingsCollection(site);
|
|
94
|
+
if (!collection) return text;
|
|
95
|
+
let doc: SettingsDocument | null = null;
|
|
96
|
+
if (text !== null) {
|
|
97
|
+
try {
|
|
98
|
+
doc = parseAnyDocument(JSON.parse(text), site, collection) as SettingsDocument;
|
|
99
|
+
} catch {
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (doc === null && Object.keys(order).length === 0) return text;
|
|
104
|
+
return applyKeyedEdits(text, { title: doc?.title ?? 'Site Settings', data: { ...(doc?.data ?? {}), pageOrder: order } }, collection);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The sentence an order-only Apply is refused with when Site Settings, which holds the order, can't be read. */
|
|
108
|
+
export const UNREADABLE_SETTINGS_MESSAGE = "Site Settings isn't valid JSON, so the new order can't be saved there. Nothing was changed: fix Site Settings, then apply again.";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* An order-only Apply (Decision 21) writes nothing but Site Settings, so one that can't be read
|
|
112
|
+
* is refused (422 invalid-json) rather than answered `{ ok: true }` with the order dropped.
|
|
113
|
+
* `text` null (no file yet) is fine: the order makes a new one.
|
|
114
|
+
*/
|
|
115
|
+
export function assertSettingsReadable(site: Pick<Site, 'collections' | 'blocks'>, text: string | null): void {
|
|
116
|
+
const collection = settingsCollection(site);
|
|
117
|
+
if (!collection || text === null) return;
|
|
118
|
+
try {
|
|
119
|
+
parseAnyDocument(JSON.parse(text), site, collection);
|
|
120
|
+
} catch {
|
|
121
|
+
throw refused(422, 'invalid-json', UNREADABLE_SETTINGS_MESSAGE);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The ordered collection's path changes in a list of moves. */
|
|
126
|
+
function pathChanges(site: Pick<Site, 'collections'>, moves: readonly PathMove[]): PathChange[] {
|
|
127
|
+
const pages = orderedCollection(site);
|
|
128
|
+
return pages ? moves.filter((m) => m.collection === pages.name).map((m) => ({ from: m.path, to: m.to })) : [];
|
|
129
|
+
}
|
|
130
|
+
const pathsOf = (site: Pick<Site, 'collections'>, documents: readonly DocumentEntry[]) => {
|
|
131
|
+
const pages = orderedCollection(site);
|
|
132
|
+
return pages ? documents.filter((d) => d.collection === pages.name).map((d) => d.path) : [];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Change address… (Decision 6): the engine's Site Settings edit that keeps a saved order current after these moves. */
|
|
136
|
+
export function orderEdit(site: Site, moves: readonly PathMove[], documents: readonly DocumentEntry[]): (text: string) => string {
|
|
137
|
+
const changes = pathChanges(site, moves);
|
|
138
|
+
const before = pathsOf(site, documents);
|
|
139
|
+
return (text) => {
|
|
140
|
+
const saved = orderIn(text);
|
|
141
|
+
if (changes.length === 0 || Object.keys(saved).length === 0) return text;
|
|
142
|
+
return settingsWithOrder(site, text, orderAfterMoves(saved, changes, before)) ?? text;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ArrangePlan {
|
|
147
|
+
/** Every document whose path changes, roots first (each root, then what it carries); empty for an order-only Apply. */
|
|
148
|
+
moves: PathMove[];
|
|
149
|
+
/** Site Settings' text → its text with the arranged order (the engine's `editSettings`, or the order-only write). */
|
|
150
|
+
edit: (text: string | null) => string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The Apply's plan against the listing as it is (`documents`): the roots expanded, the
|
|
155
|
+
* order checked against the Pages collection as it will be. Throws WriteRefused: the
|
|
156
|
+
* engine's own refusals for a root (move.ts → `movesUnder`), `not-found` (404) for an
|
|
157
|
+
* order entry that names nothing once the moves land.
|
|
158
|
+
*/
|
|
159
|
+
export function planArrange(site: Site, request: ArrangeRequest, documents: readonly DocumentEntry[]): ArrangePlan {
|
|
160
|
+
// Decision 10: the review's listing, collection by collection, before anything is planned.
|
|
161
|
+
for (const [collection, stamp] of Object.entries(request.expect ?? {})) {
|
|
162
|
+
const paths = documents.filter((d) => d.collection === collection).map((d) => d.path);
|
|
163
|
+
if (listingStamp(paths) !== stamp) throw refused(409, 'changed', CHANGED_MESSAGE);
|
|
164
|
+
}
|
|
165
|
+
const key = (m: { collection: string; path: string }) => `${m.collection}\0${m.path}`;
|
|
166
|
+
// Shallowest first, so an inner root wins for its own subtree.
|
|
167
|
+
const roots = [...request.moves].sort((a, b) => a.fromPath.split('/').length - b.fromPath.split('/').length);
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
for (const r of roots) {
|
|
170
|
+
const k = key({ collection: r.collection, path: r.fromPath });
|
|
171
|
+
if (seen.has(k)) throw refused(400, 'bad-request', `${r.fromPath} is in the plan twice.`);
|
|
172
|
+
seen.add(k);
|
|
173
|
+
}
|
|
174
|
+
const planned = new Map<string, PathMove>();
|
|
175
|
+
for (const r of roots) {
|
|
176
|
+
for (const m of movesUnder(site, { collection: r.collection, path: r.fromPath, to: r.toPath }, documents)) planned.set(key(m), m);
|
|
177
|
+
}
|
|
178
|
+
// Roots in the order asked, each followed by what it carries (what the innermost root above it carries).
|
|
179
|
+
const ownerOf = (m: PathMove) =>
|
|
180
|
+
request.moves.filter((o) => o.collection === m.collection && (m.path === o.fromPath || m.path.startsWith(`${o.fromPath}/`))).sort((a, b) => b.fromPath.length - a.fromPath.length)[0]!;
|
|
181
|
+
const ordered: PathMove[] = [];
|
|
182
|
+
for (const r of request.moves) {
|
|
183
|
+
ordered.push(planned.get(key({ collection: r.collection, path: r.fromPath }))!);
|
|
184
|
+
for (const m of planned.values()) if (m.path !== r.fromPath && ownerOf(m) === r) ordered.push(m);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The Pages collection once the moves land: every `order` key and slug names something there.
|
|
188
|
+
const changes = pathChanges(site, ordered);
|
|
189
|
+
const before = pathsOf(site, documents);
|
|
190
|
+
const moving = new Map(changes.map((c) => [c.from, c.to]));
|
|
191
|
+
const after = before.map((p) => moving.get(p) ?? p);
|
|
192
|
+
const known = new Set<string>(['']);
|
|
193
|
+
for (const p of after) {
|
|
194
|
+
const parts = p.split('/');
|
|
195
|
+
for (let i = 1; i <= parts.length; i++) known.add(parts.slice(0, i).join('/'));
|
|
196
|
+
}
|
|
197
|
+
for (const [parent, slugs] of Object.entries(request.order)) {
|
|
198
|
+
if (!known.has(parent)) throw refused(404, 'not-found', `There's no page or group at /${parent} any more.`);
|
|
199
|
+
for (const slug of slugs) {
|
|
200
|
+
const path = parent === '' ? slug : `${parent}/${slug}`;
|
|
201
|
+
if (!known.has(path)) throw refused(404, 'not-found', `There's no page or group at /${path} any more.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const edit = (text: string | null) => settingsWithOrder(site, text, arrangedOrder(orderIn(text), request.order, changes, before));
|
|
205
|
+
return { moves: ordered, edit };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── The route ──────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
|
|
211
|
+
new Response(JSON.stringify(body), {
|
|
212
|
+
status,
|
|
213
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/** What the route needs of the drafts store. */
|
|
217
|
+
export interface Arranges {
|
|
218
|
+
arrange(request: ArrangeRequest, context?: WriteContext): Promise<ArrangeResponse>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** POST /_caracms/arrange: a Request in, a Response out, through the mode's store (move.ts's handling, the same checks). */
|
|
222
|
+
export async function handleArrange(request: Request, store: Arranges, who: { person?: Person | null } = {}): Promise<Response> {
|
|
223
|
+
try {
|
|
224
|
+
if (request.method !== 'POST') throw new WriteRefused(405, 'The arrange route only takes POST.');
|
|
225
|
+
checkOrigin(request);
|
|
226
|
+
const type = request.headers.get('content-type') ?? '';
|
|
227
|
+
if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
|
|
228
|
+
const length = Number(request.headers.get('content-length') ?? 0);
|
|
229
|
+
if (length > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
230
|
+
const raw = await request.text();
|
|
231
|
+
if (Buffer.byteLength(raw) > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
232
|
+
let body: unknown;
|
|
233
|
+
try {
|
|
234
|
+
body = JSON.parse(raw);
|
|
235
|
+
} catch {
|
|
236
|
+
throw refused(400, 'bad-request', 'Malformed body: it isn\'t valid JSON.');
|
|
237
|
+
}
|
|
238
|
+
const context: WriteContext = { person: who.person ?? null, tab: tabOf(request), release: false, seq: seqOf(request) };
|
|
239
|
+
return json(200, await store.arrange(parseArrangeBody(body), context));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body }, error.status === 405 ? { Allow: 'POST' } : {});
|
|
242
|
+
console.error('[caracms] arrange failed:', error);
|
|
243
|
+
return json(500, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Everything reads at request time (never imported, Decision 5), and keeps no state.
|
|
8
8
|
*/
|
|
9
|
-
import { stat } from 'node:fs/promises';
|
|
9
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
|
|
12
12
|
import { diskSource, listDocuments, readDocument, type ContentSource } from '../../content.ts';
|
|
13
|
-
import type { AnyOpenedDocument, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
|
|
13
|
+
import type { AnyOpenedDocument, ArrangeRequest, ArrangeResponse, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
|
|
14
14
|
import { confineOnDisk, createOnDisk, deleteOnDisk, planCreate, planDelete, target } from './create.ts';
|
|
15
|
+
import { assertSettingsReadable, orderEdit, planArrange } from './arrange.ts';
|
|
15
16
|
import { movesChanges, movesOnDisk, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest } from './move.ts';
|
|
16
17
|
import { replaceChanges, replaceResponse } from './replace.ts';
|
|
17
18
|
import { applyWrite, fileVersion, writeAtomic, type WriteContext, type WriteRequest, type WriteResponse } from './write.ts';
|
|
@@ -66,6 +67,13 @@ export interface DraftsStore {
|
|
|
66
67
|
* autosave, all rolled back on a failure, all going out at the next Publish. Throws WriteRefused.
|
|
67
68
|
*/
|
|
68
69
|
moveMany(request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse>;
|
|
70
|
+
/**
|
|
71
|
+
* Arrange pages (Contracts → Apply request): the roots expanded and checked (arrange.ts →
|
|
72
|
+
* `planArrange`), then the moves through `moveMany` with the arranged order as its Site
|
|
73
|
+
* Settings edit, landing or rolling back together; with no moves, Site Settings alone
|
|
74
|
+
* (live mode: under its lock, Decision 21). Throws WriteRefused.
|
|
75
|
+
*/
|
|
76
|
+
arrange(request: ArrangeRequest, context?: WriteContext): Promise<ArrangeResponse>;
|
|
69
77
|
/**
|
|
70
78
|
* M6c G4 (Decision 9): Replace… on a file. Every link to `from`, in every document, points
|
|
71
79
|
* at `to` (replace.ts has the rules, the same in both stores). Local: each file written
|
|
@@ -125,10 +133,32 @@ export function localDraftsStore(site: Site): DraftsStore {
|
|
|
125
133
|
async move(request) {
|
|
126
134
|
// The file first (a link out of the collection is refused before anything else), then the rules.
|
|
127
135
|
await confineOnDisk(site, target(site, request.collection, request.path).file);
|
|
128
|
-
const
|
|
129
|
-
|
|
136
|
+
const documents = await listDocuments(site);
|
|
137
|
+
const moves = movesUnder(site, request, documents);
|
|
138
|
+
// Arrange pages (Decision 6): a saved order follows the move, in the same Site Settings write.
|
|
139
|
+
return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: orderEdit(site, moves, documents) });
|
|
130
140
|
},
|
|
131
141
|
moveMany,
|
|
142
|
+
async arrange(request) {
|
|
143
|
+
// The files first (a link out of the collection is refused before anything else), then the plan.
|
|
144
|
+
for (const m of request.moves) await confineOnDisk(site, target(site, m.collection, m.fromPath).file);
|
|
145
|
+
const plan = planArrange(site, request, await listDocuments(site));
|
|
146
|
+
// An unreadable Site Settings would drop the order and the redirects without a word: refuse every Apply.
|
|
147
|
+
assertSettingsReadable(site, await diskSource.readText(join(site.root, SITE_SETTINGS_FILE)));
|
|
148
|
+
if (plan.moves.length) {
|
|
149
|
+
await moveMany({ moves: plan.moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: (text) => plan.edit(text) ?? text });
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
const file = join(site.root, SITE_SETTINGS_FILE);
|
|
153
|
+
const before = await diskSource.readText(file);
|
|
154
|
+
assertSettingsReadable(site, before);
|
|
155
|
+
const after = plan.edit(before);
|
|
156
|
+
if (after !== null && after !== before) {
|
|
157
|
+
if (before === null) await writeFile(file, after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
158
|
+
else await writeAtomic(file, after);
|
|
159
|
+
}
|
|
160
|
+
return { ok: true };
|
|
161
|
+
},
|
|
132
162
|
async replaceFile(request) {
|
|
133
163
|
const changes = await replaceChanges(site, request, await listDocuments(site), (file) => diskSource.readText(join(site.root, file)));
|
|
134
164
|
// As a local move writes its relinked files: each confined and atomic, in listing order.
|
|
@@ -46,7 +46,7 @@ import { documentUrl, type DocumentEntry } from '../../content.ts';
|
|
|
46
46
|
import { heldBases, withHeldBases, type HeldBase } from '../../held.ts';
|
|
47
47
|
import { own, parseAnyDocument, type SettingsDocument } from '../../document.ts';
|
|
48
48
|
import type { Person } from '../../live/messages.ts';
|
|
49
|
-
import { redirectsAddedBy, redirectsAfterMoves, type Redirect } from '../../redirects.ts';
|
|
49
|
+
import { redirectsAddedBy, redirectsAfterMoves, type LiveHome, type Redirect } from '../../redirects.ts';
|
|
50
50
|
import { relinkText, type AddressMove } from '../../relink.ts';
|
|
51
51
|
import { schemaRef, stampSchema } from '../../schema-key.ts';
|
|
52
52
|
import { applyKeyedEdits } from '../../serialize.ts';
|
|
@@ -267,14 +267,16 @@ const settingsCollection = (site: Site): ResolvedCollection | undefined => Objec
|
|
|
267
267
|
/**
|
|
268
268
|
* Site Settings' text once its redirects follow every move (redirects.ts →
|
|
269
269
|
* `redirectsAfterMoves`: all at once, against the list before the plan, so a swap or a
|
|
270
|
-
* chain comes out right; `add` per move
|
|
271
|
-
*
|
|
270
|
+
* chain comes out right; `add` per move; `homes`, live mode's documents whose live address
|
|
271
|
+
* is elsewhere, for a live address a move vacates), its own links are rewritten, and the
|
|
272
|
+
* caller's own `edit` is applied; null when nothing changes. `text` null: there's no Site Settings file yet.
|
|
272
273
|
*/
|
|
273
274
|
export function settingsAfterMoves(
|
|
274
275
|
site: Site,
|
|
275
276
|
text: string | null,
|
|
276
277
|
moves: readonly { move: AddressMove; add: boolean }[],
|
|
277
278
|
edit?: (text: string) => string,
|
|
279
|
+
homes: readonly LiveHome[] = [],
|
|
278
280
|
): string | null {
|
|
279
281
|
const collection = settingsCollection(site);
|
|
280
282
|
if (!collection) return null;
|
|
@@ -288,7 +290,7 @@ export function settingsAfterMoves(
|
|
|
288
290
|
}
|
|
289
291
|
}
|
|
290
292
|
const doc = parseAnyDocument(raw, site, collection) as SettingsDocument;
|
|
291
|
-
const redirects = redirectsAfterMoves(doc.data.redirects ?? [], moves.map((m) => ({ from: m.move.fromUrl, to: m.move.toUrl, add: m.add })));
|
|
293
|
+
const redirects = redirectsAfterMoves(doc.data.redirects ?? [], moves.map((m) => ({ from: m.move.fromUrl, to: m.move.toUrl, add: m.add })), homes);
|
|
292
294
|
const title = relinked === null ? 'Site Settings' : doc.title;
|
|
293
295
|
let after = applyKeyedEdits(relinked, { title, data: { ...doc.data, redirects } }, collection);
|
|
294
296
|
if (edit) after = edit(after);
|
|
@@ -324,6 +326,14 @@ export function settingsWithoutRedirectFrom(site: Site, text: string | null, url
|
|
|
324
326
|
* What the plan changes, from the store's own texts (`readText` by site-relative file;
|
|
325
327
|
* live mode's has the autosaves on top): each linking file rewritten once for all the
|
|
326
328
|
* moves that touch it. `addRedirect(plan)`: whether that address gets `old → new`.
|
|
329
|
+
*
|
|
330
|
+
* `liveAddressOf(file)` (live mode, passed only when the move leaves redirects): a
|
|
331
|
+
* document's live address by its file before the plan, when it isn't where the document is
|
|
332
|
+
* (moved since the last Publish; autosaves.ts → `addresses`), else null. When a move
|
|
333
|
+
* vacates such an address and no document takes it, it redirects to where that document
|
|
334
|
+
* is once the plan has landed (redirects.ts → `redirectsAfterMoves`'s `homes`): after a
|
|
335
|
+
* chain A → B, B → C, B's document has no redirect from B (A's sat there), so A's moving
|
|
336
|
+
* on from B leaves `B → C`.
|
|
327
337
|
*/
|
|
328
338
|
export async function movesChanges(
|
|
329
339
|
site: Site,
|
|
@@ -332,6 +342,7 @@ export async function movesChanges(
|
|
|
332
342
|
readText: (file: string) => Promise<string | null>,
|
|
333
343
|
addRedirect: (plan: MovePlan) => boolean,
|
|
334
344
|
editSettings?: (text: string) => string,
|
|
345
|
+
liveAddressOf?: (file: string) => string | null,
|
|
335
346
|
): Promise<MoveChanges> {
|
|
336
347
|
const moves = plans.map((p) => p.move);
|
|
337
348
|
const collectionOf = (name: string) => site.collections[name]!;
|
|
@@ -354,23 +365,46 @@ export async function movesChanges(
|
|
|
354
365
|
}
|
|
355
366
|
const before = await readText(SITE_SETTINGS_FILE);
|
|
356
367
|
const adds = plans.map((p) => ({ move: p.move, add: addRedirect(p) }));
|
|
357
|
-
const
|
|
368
|
+
const homes = liveAddressOf ? liveHomes(site, plans, documents, liveAddressOf) : [];
|
|
369
|
+
const after = settingsAfterMoves(site, before, adds, editSettings, homes);
|
|
358
370
|
const settings = settingsCollection(site);
|
|
359
371
|
return {
|
|
360
372
|
moved,
|
|
361
373
|
relinked,
|
|
362
374
|
settings: after === null ? null : { before, after },
|
|
363
|
-
redirects: redirectsAddedBy(adds.map((a) => ({ from: a.move.fromUrl, to: a.move.toUrl, add: a.add }))),
|
|
375
|
+
redirects: redirectsAddedBy(adds.map((a) => ({ from: a.move.fromUrl, to: a.move.toUrl, add: a.add })), homes),
|
|
364
376
|
settingsRelinked: !!settings && before !== null && relinkText(before, moves, site, settings).links > 0,
|
|
365
377
|
};
|
|
366
378
|
}
|
|
367
379
|
|
|
380
|
+
/** Each document with a live address elsewhere (`liveAddressOf`), and its address once the plan has landed. */
|
|
381
|
+
function liveHomes(site: Site, plans: readonly MovePlan[], documents: readonly DocumentEntry[], liveAddressOf: (file: string) => string | null): LiveHome[] {
|
|
382
|
+
const planned = new Map(plans.map((p) => [p.from.file, p]));
|
|
383
|
+
let sited: Site | null = null;
|
|
384
|
+
const homes: LiveHome[] = [];
|
|
385
|
+
for (const d of documents) {
|
|
386
|
+
const live = liveAddressOf(d.file);
|
|
387
|
+
if (live === null) continue;
|
|
388
|
+
const plan = planned.get(d.file);
|
|
389
|
+
let now: string | null;
|
|
390
|
+
if (plan) now = plan.to.url;
|
|
391
|
+
else {
|
|
392
|
+
// Not in the plan: its address doesn't change (a held document whose holder moves is in it).
|
|
393
|
+
sited ??= withHeldBases(site, heldBases(site.collections, documents));
|
|
394
|
+
const collection = sited.collections[d.collection];
|
|
395
|
+
now = collection ? documentUrl(collection, d.path) : null;
|
|
396
|
+
}
|
|
397
|
+
if (now !== null) homes.push({ live, now });
|
|
398
|
+
}
|
|
399
|
+
return homes;
|
|
400
|
+
}
|
|
401
|
+
|
|
368
402
|
/** The route's answer for a plan done: the first plan's document is the one asked about. */
|
|
369
403
|
export function moveResponse(plans: readonly MovePlan[], changes: MoveChanges): MoveResponse {
|
|
370
404
|
const [first] = plans;
|
|
371
405
|
const relinked = changes.relinked.map(({ collection, path, title }) => ({ collection, path, title }));
|
|
372
406
|
if (changes.settingsRelinked) relinked.push({ collection: 'settings', path: 'site-settings', title: 'Site Settings' });
|
|
373
|
-
const redirectOf = (p: MovePlan) => changes.redirects.find((r) => r.from === p.move.fromUrl) ?? null;
|
|
407
|
+
const redirectOf = (p: MovePlan) => changes.redirects.find((r) => r.from === p.move.fromUrl && r.to === p.move.toUrl) ?? null;
|
|
374
408
|
return {
|
|
375
409
|
ok: true,
|
|
376
410
|
collection: first!.to.collection,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Arrange pages (notes/2026-10-25-arrange-pages.md; the mockup's notes/mockups/arrange-pages.html, as drawn).
|
|
3
|
+
The screen takes the preview's place as a light sheet over the stage, the way a collection's table does
|
|
4
|
+
(collections.css → .stage-table); the pane holds its level ("Arrange pages", then "Review").
|
|
5
|
+
Rows are the Pages tree's rows grown wide: a grip, the name at its depth (16px steps), the address, the row's end.
|
|
6
|
+
Marks are washes, full rings, chips and labels. No side rails anywhere (M2 Decision 10).
|
|
7
|
+
*/
|
|
8
|
+
:root{
|
|
9
|
+
--arr-max-w:940px; /* the tree's width on the stage: names and addresses side by side, left-aligned under its head */
|
|
10
|
+
--arr-row-h:32px; /* a row: a touch taller than the sidebar's 30px, a bigger target to drag and drop on */
|
|
11
|
+
--arr-grip-w:24px; /* the grip's column; a row's icon starts after it, then one --tree-indent per depth */
|
|
12
|
+
--arr-addr-w:300px; /* the address column, at its widest */
|
|
13
|
+
--arr-addr-min:140px; /* the address column at its narrowest (the editor's 1,024px floor), where it gives way to the name first */
|
|
14
|
+
--arr-name-min:200px; /* the name column's narrowest: an icon, a name cut short, and a mark or two */
|
|
15
|
+
--arr-end-w:184px; /* a row's end: its actions (Rename…, Remove), the inside tag, the twisty */
|
|
16
|
+
--arr-mark:2px; /* the beside line's weight; the inside ring is the same 2px, all the way round */
|
|
17
|
+
--arr-ghost-w:300px; /* the dragged card's widest */
|
|
18
|
+
}
|
|
19
|
+
.stage.is-arrange .frame{visibility:hidden}
|
|
20
|
+
.arr{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;min-height:0;background:var(--surface);color:var(--text);animation:fade-in var(--dur-base) var(--ease-out)}
|
|
21
|
+
.arr-head{flex:none;padding:var(--space-4) var(--space-6) var(--space-3);border-bottom:1px solid var(--border)}
|
|
22
|
+
.arr-head-in{max-width:var(--arr-max-w);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:var(--space-2) var(--space-3)}
|
|
23
|
+
.arr-title{display:flex;align-items:baseline;gap:var(--space-2);min-width:0}
|
|
24
|
+
.arr-title h2{margin:0;font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);white-space:nowrap}
|
|
25
|
+
.arr-title .n-chip{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-subtle);white-space:nowrap}
|
|
26
|
+
.arr-hint{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-1-5) var(--space-5);font-size:var(--text-sm);color:var(--text-muted)}
|
|
27
|
+
.arr-ro{display:inline-flex;align-items:center;gap:var(--space-1-5);font-size:var(--text-sm);color:var(--text-muted)}
|
|
28
|
+
/* The two markers' key, drawn as the markers themselves */
|
|
29
|
+
.key{display:inline-flex;align-items:center;gap:var(--space-2);white-space:nowrap}
|
|
30
|
+
.key-line{position:relative;width:26px;height:var(--arr-mark);border-radius:2px;background:var(--select)}
|
|
31
|
+
.key-line::before{content:"";position:absolute;left:-3px;top:50%;width:6px;height:6px;translate:0 -50%;border:2px solid var(--select);border-radius:50%;background:var(--surface)}
|
|
32
|
+
.key-ring{width:26px;height:14px;border-radius:var(--radius-sm);background:var(--select-wash);box-shadow:0 0 0 var(--arr-mark) var(--select)}
|
|
33
|
+
.arr-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:0 var(--space-6) var(--space-10);scrollbar-width:thin;scrollbar-color:var(--border-hover) transparent}
|
|
34
|
+
.arr-cols{position:sticky;top:0;z-index:2;max-width:var(--arr-max-w);display:grid;grid-template-columns:var(--arr-grip-w) minmax(var(--arr-name-min),1fr) minmax(var(--arr-addr-min),var(--arr-addr-w)) var(--arr-end-w);align-items:center;height:34px;margin-bottom:var(--space-1);background:var(--surface);border-bottom:1px solid var(--border);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-subtle)}
|
|
35
|
+
.arr-tree{position:relative;max-width:var(--arr-max-w)}
|
|
36
|
+
/* A row */
|
|
37
|
+
.arow{position:relative;display:grid;grid-template-columns:var(--arr-grip-w) minmax(var(--arr-name-min),1fr) minmax(var(--arr-addr-min),var(--arr-addr-w)) var(--arr-end-w);align-items:center;min-height:var(--arr-row-h);border:1px solid transparent;border-radius:var(--radius-md);transition:background var(--dur-fast),border-color var(--dur-fast),box-shadow var(--dur-fast)}
|
|
38
|
+
.arow:hover{background:var(--surface-hover)}
|
|
39
|
+
.arow[data-drag]{cursor:grab}
|
|
40
|
+
.ag{display:grid;place-items:center;align-self:stretch;color:var(--icon)}
|
|
41
|
+
.arow[data-drag]:hover .ag{color:var(--text)}
|
|
42
|
+
.an{display:flex;align-items:center;gap:var(--space-2);min-width:0;overflow:hidden;padding-left:calc(var(--depth,0) * var(--tree-indent));padding-right:var(--space-2)}
|
|
43
|
+
.an>.i{flex:none;color:var(--icon)}
|
|
44
|
+
.an .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
45
|
+
.an .meta{display:inline-flex;flex:0 1 auto;align-items:center;gap:var(--space-1-5);min-width:0;overflow:hidden;font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap}
|
|
46
|
+
.an .meta .chip{flex:none;height:18px;font-size:var(--text-2xs)}
|
|
47
|
+
.an .meta>:not(.chip){min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
48
|
+
.an .cnt{font-variant-numeric:tabular-nums}
|
|
49
|
+
.aa{min-width:0;padding-right:var(--space-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--text-xs)/1.3 var(--font-mono);color:var(--text-muted)}
|
|
50
|
+
.aa .new{color:var(--select-strong);font-weight:var(--weight-medium)}
|
|
51
|
+
.aa .was{margin-left:var(--space-2);color:var(--text-subtle);font-family:var(--font-ui)}
|
|
52
|
+
.aa .was .mono{font-family:var(--font-mono)}
|
|
53
|
+
.ae{display:flex;align-items:center;justify-content:flex-end;gap:var(--space-1);min-width:0;padding-right:var(--space-1)}
|
|
54
|
+
.ae .act{opacity:0;transition:opacity var(--dur-fast)}
|
|
55
|
+
.arow:hover .ae .act,.arow:focus-within .ae .act{opacity:1}
|
|
56
|
+
.ae .carried{font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
57
|
+
.atw{display:grid;place-items:center;width:calc(var(--tree-twisty) + 6px);height:calc(var(--tree-twisty) + 6px);flex:none;padding:0;border:0;border-radius:var(--radius-sm);background:none;color:var(--icon);transition:background var(--dur-fast),color var(--dur-fast)}
|
|
58
|
+
.atw:hover{background:var(--surface-pressed);color:var(--text)}
|
|
59
|
+
.atw .i{transition:rotate var(--dur-fast) var(--ease-out)}
|
|
60
|
+
.atw[aria-expanded="true"] .i{rotate:90deg}
|
|
61
|
+
.atw-gap{width:calc(var(--tree-twisty) + 6px);flex:none}
|
|
62
|
+
/* Home: fixed, first, takes nothing */
|
|
63
|
+
.arow.is-home .an .nm{font-weight:var(--weight-medium)}
|
|
64
|
+
/* A held collection's row: moves with its page; nothing drags it, nothing drops on it */
|
|
65
|
+
.arow.is-held{color:var(--text-muted)}
|
|
66
|
+
.arow.is-held:hover{background:none}
|
|
67
|
+
/* Marks for what's changed here and not applied yet: the tool's blue in a chip, and the new address in blue */
|
|
68
|
+
.arow.is-gone .an .nm,.arow.is-gone .an>.i{color:var(--text-subtle)}
|
|
69
|
+
.arow.is-gone .aa{color:var(--text-subtle);font-family:var(--font-ui)}
|
|
70
|
+
/* A new group with nothing in it: a full dashed ring (an empty slot, like .empty and .add-row), and its line under it */
|
|
71
|
+
.arow.is-empty{border:1px dashed var(--border-strong)}
|
|
72
|
+
.arow-sub{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-1-5) var(--space-2);min-height:24px;margin:0 0 var(--space-1);padding-left:calc(var(--arr-grip-w) + var(--depth,0) * var(--tree-indent) + 24px);font-size:var(--text-sm);color:var(--text-muted)}
|
|
73
|
+
.arow-sub.err{color:var(--status-danger);font-weight:var(--weight-medium)}
|
|
74
|
+
.arow-sub .i{flex:none}
|
|
75
|
+
/* Naming a new group, or renaming one, in its row */
|
|
76
|
+
.arow.is-naming{background:var(--surface-sunken);border-color:var(--border)}
|
|
77
|
+
.arow.is-naming:hover{background:var(--surface-sunken)}
|
|
78
|
+
.arow.is-naming .an .input{width:min(260px,100%);min-height:26px;height:26px;padding:2px 8px;font-size:var(--text-base)}
|
|
79
|
+
.arow.is-naming .ae .btn{flex:none}
|
|
80
|
+
/* Linked from the review: hovering an entry there washes its row here */
|
|
81
|
+
.arow.is-linked{background:var(--select-wash)}
|
|
82
|
+
/* Dragging. The row being dragged keeps its place as a dashed outline (the block list's ghost, .brow.is-ghost); what's under it goes with it. */
|
|
83
|
+
.arow.is-src{background:none;border:1px dashed var(--border-strong)}
|
|
84
|
+
.arow.is-src>*{visibility:hidden}
|
|
85
|
+
/* Inside: the row it goes into gets the selection's wash and a full 2px ring, and says so at its end */
|
|
86
|
+
.arow.is-into,.arow.is-into:hover{background:var(--select-wash);border-color:var(--select);box-shadow:0 0 0 1px var(--select)}
|
|
87
|
+
.arow.is-into .an .nm{color:var(--select-strong);font-weight:var(--weight-semibold)}
|
|
88
|
+
.arow.is-into .an>.i{color:var(--select-strong)}
|
|
89
|
+
.tag{display:inline-flex;align-items:center;gap:var(--space-1);height:20px;padding:0 var(--space-2);border-radius:var(--radius-pill);background:var(--select);color:var(--on-fill);font-size:var(--text-xs);font-weight:var(--weight-semibold);white-space:nowrap}
|
|
90
|
+
/* Refused: a danger wash and a full thin ring, and the reason in one line across the address and the end */
|
|
91
|
+
.arow.is-refused,.arow.is-refused:hover{background:var(--status-danger-wash);border-color:var(--status-danger-border)}
|
|
92
|
+
.arow-why{grid-column:3 / 5;display:flex;align-items:center;gap:var(--space-1-5);min-width:0;padding-right:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--status-danger);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
93
|
+
/* Beside: a 2px line between two rows, starting where that level's icons start (so its indent says which level), a ring at its
|
|
94
|
+
start, and the level it lands in at its end. The line is a marker across the row, never a bar down a card's side. */
|
|
95
|
+
.amark{position:absolute;right:0;z-index:4;height:var(--arr-mark);border-radius:2px;background:var(--select);box-shadow:0 0 0 2px var(--surface);pointer-events:none}
|
|
96
|
+
.amark::before{content:"";position:absolute;left:-4px;top:50%;width:8px;height:8px;translate:0 -50%;border:2px solid var(--select);border-radius:50%;background:var(--surface)}
|
|
97
|
+
.amark .tag{position:absolute;right:var(--space-1);top:50%;translate:0 -50%;box-shadow:0 0 0 2px var(--surface)}
|
|
98
|
+
/* The card under the pointer while dragging */
|
|
99
|
+
.adrag{position:fixed;left:0;top:0;z-index:70;display:grid;grid-template-columns:16px minmax(0,1fr);align-items:center;gap:2px var(--space-2);min-width:220px;max-width:var(--arr-ghost-w);padding:var(--space-2) var(--space-3) var(--space-2) var(--space-2-5);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-drag);pointer-events:none;font-size:var(--text-base)}
|
|
100
|
+
.adrag>.i{color:var(--icon)}
|
|
101
|
+
.adrag .nm{display:flex;align-items:baseline;gap:var(--space-1-5);min-width:0;font-weight:var(--weight-semibold);white-space:nowrap;overflow:hidden}
|
|
102
|
+
.adrag .nm small{font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--text-subtle);overflow:hidden;text-overflow:ellipsis}
|
|
103
|
+
.adrag .to{grid-column:2;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--text-xs)/1.35 var(--font-mono);color:var(--select-strong)}
|
|
104
|
+
.adrag .to.plain{font-family:var(--font-ui);color:var(--text-muted)}
|
|
105
|
+
.adrag.no{border-color:var(--status-danger-border)}
|
|
106
|
+
.adrag.no .to{display:flex;align-items:center;gap:var(--space-1);font-family:var(--font-ui);font-weight:var(--weight-medium);color:var(--status-danger)}
|
|
107
|
+
body.is-dragging,body.is-dragging *{cursor:grabbing!important;user-select:none}
|
|
108
|
+
body.is-dragging.is-no,body.is-dragging.is-no *{cursor:not-allowed!important}
|
|
109
|
+
|
|
110
|
+
/* The pane's levels: Arrange pages, then Review */
|
|
111
|
+
.arr-none{font-size:var(--text-sm);color:var(--text-muted)}
|
|
112
|
+
.chg{list-style:none;margin:0 calc(-1 * var(--space-1));padding:0;display:flex;flex-direction:column;gap:1px}
|
|
113
|
+
.chg li{display:grid;grid-template-columns:16px minmax(0,1fr);gap:var(--space-2);padding:var(--space-1-5) var(--space-1);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--text-muted);line-height:1.4}
|
|
114
|
+
.chg li .i{margin-top:1px;color:var(--icon)}
|
|
115
|
+
.chg li b{color:var(--text);font-weight:var(--weight-semibold)}
|
|
116
|
+
.chg li.is-muted b{color:var(--text-muted)}
|
|
117
|
+
.arr-foot{flex:none;padding:var(--space-3) var(--space-4) var(--space-4);border-top:1px solid var(--border);background:var(--surface)}
|
|
118
|
+
.arr-foot .why{display:grid;grid-template-columns:16px minmax(0,1fr);gap:var(--space-1-5);margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--text-muted)}
|
|
119
|
+
.arr-foot .why .i{margin-top:1px;color:var(--icon)}
|
|
120
|
+
.arr-foot .why b{color:var(--text);font-weight:var(--weight-semibold)}
|
|
121
|
+
.arr-foot .busy{margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--status-waiting)}
|
|
122
|
+
.arr-foot .acts{display:flex;justify-content:flex-end;gap:var(--space-2)}
|
|
123
|
+
/* The review: what moves, where, and whether old links keep working */
|
|
124
|
+
.rv-lead{margin-bottom:var(--space-4);font-size:var(--text-md);font-weight:var(--weight-semibold);line-height:1.35}
|
|
125
|
+
.rv-lead small{display:block;margin-top:2px;font-size:var(--text-sm);font-weight:var(--weight-regular);color:var(--text-muted)}
|
|
126
|
+
.rv{list-style:none;margin:0 calc(-1 * var(--space-1));padding:0;display:flex;flex-direction:column;gap:1px}
|
|
127
|
+
.rv-e{display:grid;grid-template-columns:16px minmax(0,1fr);column-gap:var(--space-2);row-gap:1px;margin-left:calc(var(--in,0) * var(--tree-indent));padding:var(--space-1-5) var(--space-1);border-radius:var(--radius-md);transition:background var(--dur-fast)}
|
|
128
|
+
.rv-e:hover{background:var(--surface-hover)}
|
|
129
|
+
.rv-e>.i{margin-top:1px;color:var(--icon)}
|
|
130
|
+
.rv-e .t{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-1) var(--space-1-5);min-width:0;font-weight:var(--weight-semibold);line-height:1.35}
|
|
131
|
+
.rv-e .t .chip{height:18px;font-size:var(--text-2xs)}
|
|
132
|
+
.rv-e.is-carried .t,.rv-e.is-held .t{font-weight:var(--weight-medium)}
|
|
133
|
+
.rv-e.is-gone .t{font-weight:var(--weight-medium);color:var(--text-muted)}
|
|
134
|
+
.rv-e.is-gone>.i{color:var(--text-subtle)}
|
|
135
|
+
.mv{grid-column:2;display:flex;flex-wrap:wrap;column-gap:var(--space-1);min-width:0;font:var(--text-xs)/1.5 var(--font-mono);color:var(--text-subtle);overflow-wrap:anywhere}
|
|
136
|
+
.mv .to{color:var(--text)}
|
|
137
|
+
.rv-more{margin-left:auto;font-size:var(--text-xs)}
|
|
138
|
+
.rv-more .i{transition:rotate var(--dur-fast) var(--ease-out)}
|
|
139
|
+
.rv-more[aria-expanded="true"] .i{rotate:90deg}
|
|
140
|
+
.rv-docs{grid-column:2;list-style:none;margin:var(--space-1-5) 0 0;padding:var(--space-1-5) var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface-sunken);font:var(--text-xs)/1.6 var(--font-mono);color:var(--text-muted);overflow-wrap:anywhere}
|
|
141
|
+
.rv-docs li.is-new{color:var(--status-warning-strong);font-weight:var(--weight-medium)}
|
|
142
|
+
.rv-tick{margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--border)}
|
|
143
|
+
.rv-tick .field-help{margin-bottom:0}
|
|
144
|
+
/* Shared pieces the mockup draws outside a dialog or a level's own CSS: the tick, and the order-only panel's fixed line */
|
|
145
|
+
.rv-tick .check{display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text);cursor:pointer}
|
|
146
|
+
.rv-tick .check input{flex:none;width:16px;height:16px;margin:0;accent-color:var(--select);cursor:inherit}
|
|
147
|
+
.rv-tick .check + .field-help{margin-top:var(--space-1);padding-left:calc(16px + var(--space-2))}
|
|
148
|
+
.rv-tick .check:has(input:disabled){cursor:not-allowed;color:var(--text-muted)}
|
|
149
|
+
.side-level[data-pane-level="arrange"] .sec-h .n{font-weight:var(--weight-medium);letter-spacing:0;color:var(--text-subtle)}
|
|
150
|
+
.arow.is-naming .input[aria-invalid="true"]{border-color:var(--status-danger);box-shadow:0 0 0 3px var(--status-danger-halo)}
|
|
151
|
+
.arr-foot .progress{margin:0 0 var(--space-2-5)}
|
|
152
|
+
.mv .fr{margin-top:0}
|