caracms 0.3.1 → 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.1",
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
+ }
@@ -149,7 +149,16 @@ 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
  }
@@ -158,6 +167,8 @@ export interface InsideOption {
158
167
  * New page's "Inside" (Decision 11): the top level, then every group and every page, at
159
168
  * any depth, in tree order. A page with nothing under it yet is offered too, so a page can
160
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.
161
172
  */
162
173
  export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
163
174
  const base = collection.urlBase ?? '/';
@@ -169,14 +180,29 @@ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'ur
169
180
  }
170
181
  for (const f of folders) if (f.split('/').includes(RESERVED_SLUG)) folders.delete(f);
171
182
  const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
172
- const options = [...folders]
173
- .sort((a, b) => a.localeCompare(b))
174
- .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) {
175
191
  const parts = path.split('/');
176
- const label = parts.map((_, i) => labelOf(parts.slice(0, i + 1).join('/'))).join(' / ');
177
- return { path, label, base: `${base}${path}/` };
178
- });
179
- 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;
180
206
  }
181
207
 
182
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 ? (
@@ -364,16 +359,7 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
364
359
  <label className="field-label" htmlFor="mv-inside">
365
360
  Inside
366
361
  </label>
367
- <select className="input" id="mv-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
368
- {options.map((o) => (
369
- <option key={o.path} value={o.path}>
370
- {o.label}
371
- {o.path ? ` (${o.base}…)` : ''}
372
- </option>
373
- ))}
374
- <option disabled>──────────</option>
375
- <option value={NEW_GROUP}>New group…</option>
376
- </select>
362
+ <InsidePicker id="mv-inside" options={options} value={inside} newGroup={NEW_GROUP} onChange={(v) => (setInside(v), setError(null))} />
377
363
  </div>
378
364
  ) : null}
379
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)}