caracms 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * New page's and Change address…'s "Inside" (Urban Return, 2026-10-24): a field that shows
3
+ * where the page goes and opens a searchable tree of every group and page in place of a
4
+ * select, which stopped working once every page was offered.
5
+ *
6
+ * - **Closed:** the chosen place's whole name (`Services › Advisory`) and the address a page
7
+ * inside it starts with.
8
+ * - **Open:** a find field over the tree (insideOptions' order: what holds pages first at each
9
+ * level), each row its own name, indented, with its address. Typing filters by name or
10
+ * address, every word, and shows each hit by its whole name. "New group…" is always last.
11
+ * - **Keys:** ↑ ↓ move, Enter picks, Esc closes the picker only (the dialog's Frame takes Esc
12
+ * first and, while `[data-inside-open]` is in it, sends INSIDE_CLOSE instead of closing), Tab
13
+ * closes it.
14
+ */
15
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react';
16
+ import { Icon } from '../shell/Icons.tsx';
17
+ import type { InsideOption } from './actions.ts';
18
+
19
+ /** The event Frame sends an open picker on Esc. */
20
+ export const INSIDE_CLOSE = 'caracms:inside-close';
21
+
22
+ const crumbs = (o: InsideOption) => o.label.split(' / ').join(' › ');
23
+
24
+ export function InsidePicker(props: { id: string; options: readonly InsideOption[]; value: string; newGroup: string; onChange: (value: string) => void }) {
25
+ const { id, options, value, newGroup } = props;
26
+ const [open, setOpen] = useState(false);
27
+ const [query, setQuery] = useState('');
28
+ const [active, setActive] = useState(0);
29
+ const trigger = useRef<HTMLButtonElement>(null);
30
+ const root = useRef<HTMLDivElement>(null);
31
+ const list = useRef<HTMLDivElement>(null);
32
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
33
+ const rows = useMemo(() => {
34
+ const hits = words.length ? options.filter((o) => words.every((w) => o.label.toLowerCase().includes(w) || o.base.toLowerCase().includes(w))) : options;
35
+ return [...hits.map((o) => ({ value: o.path, option: o })), { value: newGroup, option: null }];
36
+ }, [options, newGroup, words.join(' ')]);
37
+ const chosen = options.find((o) => o.path === value);
38
+ const close = (focus: boolean) => {
39
+ setOpen(false);
40
+ setQuery('');
41
+ if (focus) trigger.current?.focus();
42
+ };
43
+ const pick = (next: string) => {
44
+ props.onChange(next);
45
+ close(true);
46
+ };
47
+ // Opening starts on what's chosen; a new search starts at the top.
48
+ useEffect(() => {
49
+ if (open) setActive(Math.max(0, rows.findIndex((r) => r.value === value)));
50
+ }, [open]);
51
+ useEffect(() => {
52
+ if (open && query) setActive(0);
53
+ }, [query]);
54
+ useEffect(() => {
55
+ const el = root.current;
56
+ if (!open || !el) return;
57
+ const onClose = () => close(true);
58
+ el.addEventListener(INSIDE_CLOSE, onClose);
59
+ return () => el.removeEventListener(INSIDE_CLOSE, onClose);
60
+ }, [open]);
61
+ useEffect(() => {
62
+ list.current?.querySelector(`#${id}-o-${active}`)?.scrollIntoView({ block: 'nearest' });
63
+ }, [active, open, id]);
64
+ const onKey = (event: KeyboardEvent<HTMLInputElement>) => {
65
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
66
+ event.preventDefault();
67
+ const step = event.key === 'ArrowDown' ? 1 : -1;
68
+ setActive((a) => Math.min(rows.length - 1, Math.max(0, a + step)));
69
+ } else if (event.key === 'Enter') {
70
+ event.preventDefault();
71
+ const row = rows[active];
72
+ if (row) pick(row.value);
73
+ } else if (event.key === 'Tab') {
74
+ close(false);
75
+ }
76
+ };
77
+ return (
78
+ <div ref={root} className="inside-pick" data-inside-open={open ? '' : undefined}>
79
+ <button
80
+ ref={trigger}
81
+ type="button"
82
+ className="input inside-btn"
83
+ id={id}
84
+ aria-haspopup="listbox"
85
+ aria-expanded={open}
86
+ data-inside-value={value === newGroup ? '__new' : value}
87
+ // While open, keep focus in the find field so this click closes it (and doesn't blur-close, then reopen).
88
+ onMouseDown={(e) => open && e.preventDefault()}
89
+ onClick={() => (open ? close(true) : setOpen(true))}
90
+ >
91
+ <span className="nm">{value === newGroup ? 'New group…' : chosen ? crumbs(chosen) : 'Top level'}</span>
92
+ {value !== newGroup && chosen ? <span className="mono meta">{chosen.base}</span> : null}
93
+ <Icon name="chev-d" size="xs" />
94
+ </button>
95
+ {open ? (
96
+ <div className="lpop">
97
+ <div className="find">
98
+ <Icon name="search" />
99
+ <input
100
+ className="input"
101
+ value={query}
102
+ placeholder="Find a page or group"
103
+ aria-label="Find a page or group"
104
+ role="combobox"
105
+ aria-expanded="true"
106
+ aria-controls={`${id}-list`}
107
+ aria-activedescendant={`${id}-o-${active}`}
108
+ autoComplete="off"
109
+ spellCheck={false}
110
+ autoFocus
111
+ data-inside-find=""
112
+ onChange={(e) => setQuery(e.target.value)}
113
+ onKeyDown={onKey}
114
+ onBlur={(e) => {
115
+ if (!e.relatedTarget || !(e.currentTarget.closest('.inside-pick')?.contains(e.relatedTarget as Node) ?? false)) close(false);
116
+ }}
117
+ />
118
+ </div>
119
+ <div ref={list} className="lpop-list inside-list" role="listbox" id={`${id}-list`} aria-label="Inside">
120
+ {rows.map((r, i) => {
121
+ const o = r.option;
122
+ const row = {
123
+ id: `${id}-o-${i}`,
124
+ role: 'option',
125
+ 'aria-selected': r.value === value,
126
+ 'data-inside-pick': o ? o.path : '__new',
127
+ onMouseDown: (e: MouseEvent) => e.preventDefault(),
128
+ onMouseEnter: () => setActive(i),
129
+ onClick: () => pick(r.value),
130
+ };
131
+ const on = i === active ? ' is-active' : '';
132
+ if (!o) {
133
+ return (
134
+ <div key="__new" className="inside-new">
135
+ {rows.length === 1 ? <div className="sw-more">Nothing called “{query.trim()}”.</div> : null}
136
+ <div {...row} className={`drow${on}`}>
137
+ <Icon name="plus" />
138
+ <span className="nm">New group…</span>
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+ return (
144
+ <div key={o.path} {...row} className={`drow t${on}${r.value === value ? ' is-on' : ''}`} style={{ ['--depth' as string]: words.length ? 0 : o.depth }}>
145
+ <Icon name={o.page ? 'page' : 'folder'} />
146
+ <span className="nm">{words.length ? crumbs(o) : o.name}</span>
147
+ <span className="meta">
148
+ <span className="mono">{o.base}</span>
149
+ </span>
150
+ </div>
151
+ );
152
+ })}
153
+ </div>
154
+ </div>
155
+ ) : null}
156
+ </div>
157
+ );
158
+ }
@@ -11,7 +11,7 @@
11
11
  * offers, a collection's singular word, a document's public address.
12
12
  */
13
13
  import { titleCase } from '../../../fields.ts';
14
- import { slugify } from '../../../slug.ts';
14
+ import { RESERVED_SLUG, slugify } from '../../../slug.ts';
15
15
  import type { Editor } from '../editor.ts';
16
16
  import { docId, ENDPOINTS, MEDIA_ENDPOINTS, TAB_HEADER, sameDoc, type CreateDeleteRefusal, type CreateRequest, type CreateResponse, type DeleteResponse, type DocRef, type MoveResponse, type EditorAnyCollection, type ListedDocument, type ReplaceFileResponse } from '../../types.ts';
17
17
 
@@ -149,14 +149,26 @@ export function addressFor(collection: Pick<EditorAnyCollection, 'urlBase'>, pat
149
149
  export interface InsideOption {
150
150
  /** The folder's path in the collection ('' for the top level). */
151
151
  path: string;
152
+ /** Its whole name, ancestors first: `Services / Advisory`. */
152
153
  label: string;
154
+ /** Its own name: `Advisory` (`Top level` for the top level). */
155
+ name: string;
156
+ /** How deep it sits: 0 for the top level, 1 for what's straight under it. */
157
+ depth: number;
158
+ /** A page sits at this address (otherwise it's a group: a folder with no page of its own). */
159
+ page: boolean;
160
+ /** Pages sit under it already. */
161
+ holds: boolean;
153
162
  /** The address a page inside it starts with (`/services/`). */
154
163
  base: string;
155
164
  }
156
165
 
157
166
  /**
158
- * New page's "Inside" (Decision 11): the top level, then every folder that holds pages
159
- * (a group, or a page with pages under it), at any depth, in tree order.
167
+ * New page's "Inside" (Decision 11): the top level, then every group and every page, at
168
+ * any depth, in tree order. A page with nothing under it yet is offered too, so a page can
169
+ * go under it (Urban Return, 2026-10-24); Home isn't, since no address goes under `/index/`.
170
+ * At each level, what already holds pages comes first (as folders come first in a file
171
+ * list), so the site's shape is at the top while the rest are a scroll or a search away.
160
172
  */
161
173
  export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
162
174
  const base = collection.urlBase ?? '/';
@@ -164,17 +176,33 @@ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'ur
164
176
  const folders = new Set<string>();
165
177
  for (const d of docs) {
166
178
  const parts = d.path.split('/');
167
- for (let i = 1; i < parts.length; i++) folders.add(parts.slice(0, i).join('/'));
179
+ for (let i = 1; i <= parts.length; i++) folders.add(parts.slice(0, i).join('/'));
168
180
  }
181
+ for (const f of folders) if (f.split('/').includes(RESERVED_SLUG)) folders.delete(f);
169
182
  const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
170
- const options = [...folders]
171
- .sort((a, b) => a.localeCompare(b))
172
- .map((path) => {
183
+ const holds = (path: string) => docs.some((d) => d.path.startsWith(`${path}/`));
184
+ const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
185
+ const children = new Map<string, string[]>();
186
+ for (const f of folders) children.set(parentOf(f), [...(children.get(parentOf(f)) ?? []), f]);
187
+ const out: InsideOption[] = [{ path: '', label: 'Top level', name: 'Top level', depth: 0, page: false, holds: true, base }];
188
+ const walk = (parent: string) => {
189
+ const kids = (children.get(parent) ?? []).sort((a, b) => Number(holds(b)) - Number(holds(a)) || a.localeCompare(b));
190
+ for (const path of kids) {
173
191
  const parts = path.split('/');
174
- const label = parts.map((_, i) => labelOf(parts.slice(0, i + 1).join('/'))).join(' / ');
175
- return { path, label, base: `${base}${path}/` };
176
- });
177
- return [{ path: '', label: 'Top level', base }, ...options];
192
+ out.push({
193
+ path,
194
+ label: parts.map((_, i) => labelOf(parts.slice(0, i + 1).join('/'))).join(' / '),
195
+ name: labelOf(path),
196
+ depth: parts.length,
197
+ page: docs.some((d) => d.path === path),
198
+ holds: holds(path),
199
+ base: `${base}${path}/`,
200
+ });
201
+ walk(path);
202
+ }
203
+ };
204
+ walk('');
205
+ return out;
178
206
  }
179
207
 
180
208
  /** The pages under a folder (any depth), for "its pages stay" lines. */
@@ -25,6 +25,7 @@ import { docIcon } from '../nav/DocRow.tsx';
25
25
  import { Icon } from '../shell/Icons.tsx';
26
26
  import { createStore, type Store } from '../state/store.ts';
27
27
  import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
28
+ import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
28
29
  import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
29
30
  import { filesLabel, inboxFor } from '../inbox/store.ts';
30
31
  import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
@@ -92,7 +93,10 @@ export function Frame(props: { alert?: boolean; label: string; describedBy?: str
92
93
  // Closes the dialog only: the editor's Esc (up a level) and the picker's don't run.
93
94
  event.preventDefault();
94
95
  event.stopImmediatePropagation();
95
- closeDialog(editor);
96
+ // An open Inside picker closes instead, and the dialog stays.
97
+ const picker = box.current?.querySelector('[data-inside-open]');
98
+ if (picker) picker.dispatchEvent(new Event(INSIDE_CLOSE));
99
+ else closeDialog(editor);
96
100
  } else if (event.key === 'Tab' && box.current) {
97
101
  // Keep focus inside the dialog.
98
102
  const items = [...box.current.querySelectorAll<HTMLElement>('input:not([disabled]),select:not([disabled]),button:not([disabled])')];
@@ -140,7 +144,7 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
140
144
  const documents = useStore(editor.documents);
141
145
  const collection = editor.site.collections.find((c) => c.name === props.collection);
142
146
  const at = props.at ?? null;
143
- const atLabel = at ? (insideOptions(collection ?? { name: props.collection, urlBase: '/' }, documents).find((o) => o.path === at)?.label.split(' / ').pop() ?? at) : null;
147
+ const atLabel = at ? (insideOptions(collection ?? { name: props.collection, urlBase: '/' }, documents).find((o) => o.path === at)?.name ?? at) : null;
144
148
  const [name, setName] = useState(atLabel ?? '');
145
149
  const [inside, setInside] = useState(props.inside ?? '');
146
150
  const [group, setGroup] = useState('');
@@ -208,16 +212,7 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
208
212
  <label className="field-label" htmlFor="np-inside">
209
213
  Inside
210
214
  </label>
211
- <select className="input" id="np-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
212
- {options.map((o) => (
213
- <option key={o.path} value={o.path}>
214
- {o.label}
215
- {o.path ? ` (${o.base}…)` : ''}
216
- </option>
217
- ))}
218
- <option disabled>──────────</option>
219
- <option value={NEW_GROUP}>New group…</option>
220
- </select>
215
+ <InsidePicker id="np-inside" options={options} value={inside} newGroup={NEW_GROUP} onChange={(v) => (setInside(v), setError(null))} />
221
216
  </div>
222
217
  ) : null}
223
218
  {newGroup ? (
@@ -311,7 +306,8 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
311
306
  const [part, setPart] = useState(docRef.path.slice(slash + 1));
312
307
  const [error, setError] = useState<string | null>(null);
313
308
  const [busy, setBusy] = useState(false);
314
- const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents) : []), [collection, documents]);
309
+ // Every page but this one: a page can't go under itself.
310
+ const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path) : []), [collection, documents, docRef.path]);
315
311
  if (!doc || !collection || !doc.url) return null;
316
312
  const nested = collection.nested;
317
313
  const newGroup = nested && inside === NEW_GROUP;
@@ -363,16 +359,7 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
363
359
  <label className="field-label" htmlFor="mv-inside">
364
360
  Inside
365
361
  </label>
366
- <select className="input" id="mv-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
367
- {options.map((o) => (
368
- <option key={o.path} value={o.path}>
369
- {o.label}
370
- {o.path ? ` (${o.base}…)` : ''}
371
- </option>
372
- ))}
373
- <option disabled>──────────</option>
374
- <option value={NEW_GROUP}>New group…</option>
375
- </select>
362
+ <InsidePicker id="mv-inside" options={options} value={inside} newGroup={NEW_GROUP} onChange={(v) => (setInside(v), setError(null))} />
376
363
  </div>
377
364
  ) : null}
378
365
  {newGroup ? (
@@ -49,3 +49,16 @@
49
49
  .stage-aside p{color:var(--bar-muted);font-size:var(--text-sm)}
50
50
  .stage-aside .btn{margin-top:var(--space-4);background:var(--bar-control);border-color:var(--bar-control);color:var(--bar-control-text)}
51
51
  .stage-aside :focus-visible{outline-color:var(--focus-on-dark)}
52
+ /* New page's and Change address…'s Inside (Urban Return, 2026-10-24): a field that opens a
53
+ searchable tree of groups and pages, on the reference picker's popover (.lpop). */
54
+ .inside-pick{position:relative}
55
+ .input.inside-btn{display:flex;align-items:center;gap:var(--space-2);text-align:left;cursor:pointer}
56
+ .inside-btn .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
57
+ .inside-btn .meta{max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--text-2xs);color:var(--text-subtle)}
58
+ .inside-btn .i{flex:none;color:var(--icon)}
59
+ .inside-list{max-height:min(320px,45vh);overflow:auto}
60
+ .inside-list .drow{cursor:pointer}
61
+ .inside-list .drow .meta{max-width:50%;overflow:hidden}
62
+ .inside-list .drow .meta .mono{overflow:hidden;text-overflow:ellipsis}
63
+ .inside-list .drow.is-on .nm{color:var(--select-strong);font-weight:var(--weight-medium)}
64
+ .inside-new{margin-top:var(--space-1);padding-top:var(--space-1);border-top:1px solid var(--border)}