Skip to content

Commit 3eff3a4

Browse files
hhkaosclaude
andcommitted
editor: geo for meetups, event combobox, JSON-only review, select polish
- geo joins the meetup preset (a meetup has a venue worth pinning). - "Edit existing" is a filter-as-you-type combobox over the loaded events instead of a native select. - Review dialog shows only the event JSON, syntax-highlighted by a small pure tokenizer (lib/highlight.ts, tested) — no innerHTML. - Selects get a custom, properly centered caret (the native one drifted with our padding); the Profile dropdown becomes a pill. - Submit button reads "Review & submit" in both modes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8d1e3ca commit 3eff3a4

7 files changed

Lines changed: 297 additions & 103 deletions

File tree

apps/editor/index.html

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,15 @@ <h1>OTE event editor</h1>
4545
<span>Edit existing</span>
4646
</label>
4747
</div>
48-
<select id="event-select" hidden>
49-
<option value="">Loading events…</option>
50-
</select>
48+
<div id="event-combo" class="combo" hidden>
49+
<input
50+
id="event-combo-input"
51+
type="text"
52+
placeholder="Loading events…"
53+
autocomplete="off"
54+
/>
55+
<ul id="event-combo-list" hidden></ul>
56+
</div>
5157
<label class="profile-switch">
5258
Profile
5359
<select id="profile-select"></select>
@@ -63,16 +69,14 @@ <h1>OTE event editor</h1>
6369
<button id="edit-direct" type="button" class="secondary">
6470
Edit directly
6571
</button>
66-
<button id="propose" type="button" class="primary">Add event</button>
72+
<button id="propose" type="button" class="primary">
73+
Review &amp; submit
74+
</button>
6775
</div>
6876

6977
<dialog id="review">
7078
<h2>Review &amp; submit</h2>
71-
<dl id="review-summary"></dl>
72-
<details>
73-
<summary>Event JSON</summary>
74-
<pre id="review-json"></pre>
75-
</details>
79+
<pre id="review-json"></pre>
7680
<p class="hint">
7781
This opens a prefilled GitHub issue in the target repository; a
7882
maintainer (or the repo's automation) turns it into a pull request.

apps/editor/src/lib/highlight.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Tiny JSON syntax tokenizer for the review dialog. Pure: returns typed
3+
* tokens, the UI turns them into styled spans (never innerHTML).
4+
*/
5+
6+
export type TokenType = "key" | "string" | "number" | "literal" | "plain";
7+
8+
export interface Token {
9+
text: string;
10+
type: TokenType;
11+
}
12+
13+
// Order matters: key (a string followed by a colon) must win over string.
14+
const TOKEN_RE =
15+
/("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)/g;
16+
17+
/** Tokenizes pretty-printed JSON text (as produced by JSON.stringify). */
18+
export function tokenizeJson(src: string): Token[] {
19+
const tokens: Token[] = [];
20+
let last = 0;
21+
for (const match of src.matchAll(TOKEN_RE)) {
22+
const index = match.index;
23+
if (index > last) {
24+
tokens.push({ text: src.slice(last, index), type: "plain" });
25+
}
26+
const [, str, colon, num, lit] = match;
27+
if (str !== undefined) {
28+
tokens.push({ text: str, type: colon ? "key" : "string" });
29+
if (colon) tokens.push({ text: colon, type: "plain" });
30+
} else if (num !== undefined) {
31+
tokens.push({ text: num, type: "number" });
32+
} else if (lit !== undefined) {
33+
tokens.push({ text: lit, type: "literal" });
34+
}
35+
last = index + match[0].length;
36+
}
37+
if (last < src.length) {
38+
tokens.push({ text: src.slice(last), type: "plain" });
39+
}
40+
return tokens;
41+
}

apps/editor/src/lib/presets.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ const CORE_FIELDS = [
6262

6363
const PRESET_EXCLUSIONS: Record<string, ReadonlySet<string>> = {
6464
// Meetups: recurring, simple events — no cancellation workflow, no
65-
// coordinates, no data-provenance metadata.
66-
meetup: new Set(["status", "geo", "license", "source", "updatedAt"]),
67-
// Conferences add status (cancelled/postponed matters) and geo, but still
68-
// hide the provenance metadata.
65+
// data-provenance metadata.
66+
meetup: new Set(["status", "license", "source", "updatedAt"]),
67+
// Conferences add status (cancelled/postponed matters), but still hide
68+
// the provenance metadata.
6969
conference: new Set(["license", "source", "updatedAt"]),
7070
all: new Set(),
7171
};

apps/editor/src/main.ts

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { findCollisions } from "./lib/collisions.js";
8+
import { tokenizeJson } from "./lib/highlight.js";
89
import {
910
emptyFormState,
1011
fromEventJson,
@@ -228,7 +229,6 @@ async function startEditor(repo: string): Promise<void> {
228229
}
229230
badge.textContent = draftValid ? "✓ Ready" : "Incomplete";
230231
badge.className = draftValid ? "ok" : "invalid";
231-
propose.textContent = isNew ? "Add event" : "Propose change";
232232
editDirect.disabled = !isNew && editSlug === null;
233233
editDirect.title =
234234
!isNew && editSlug === null
@@ -296,7 +296,48 @@ async function startEditor(repo: string): Promise<void> {
296296
});
297297

298298
// --- event listing: contents API first, Pages feed.json fallback -------
299-
const select = el<HTMLSelectElement>("event-select");
299+
// Rendered as a filter-as-you-type combobox over the loaded events.
300+
const combo = el<HTMLDivElement>("event-combo");
301+
const comboInput = el<HTMLInputElement>("event-combo-input");
302+
const comboList = el<HTMLUListElement>("event-combo-list");
303+
304+
function eventLabel(event: OteEvent): string {
305+
const day = (event.startDate ?? "????").split("T")[0];
306+
return `${day}${event.name ?? event.id}`;
307+
}
308+
309+
function renderComboList(query: string): void {
310+
comboList.textContent = "";
311+
const q = query.trim().toLowerCase();
312+
const hits = listed
313+
.map((entry, index) => ({ entry, index }))
314+
.filter(({ entry }) => eventLabel(entry.event).toLowerCase().includes(q))
315+
.slice(0, 8);
316+
comboList.hidden = hits.length === 0;
317+
for (const { entry, index } of hits) {
318+
const li = document.createElement("li");
319+
const button = document.createElement("button");
320+
button.type = "button";
321+
button.textContent = eventLabel(entry.event);
322+
// mousedown, not click: it must win over the input's blur
323+
button.addEventListener("mousedown", (e) => {
324+
e.preventDefault();
325+
comboInput.value = eventLabel(entry.event);
326+
comboList.hidden = true;
327+
pickEvent(index);
328+
});
329+
li.append(button);
330+
comboList.append(li);
331+
}
332+
}
333+
334+
comboInput.addEventListener("focus", () => renderComboList(""));
335+
comboInput.addEventListener("input", () =>
336+
renderComboList(comboInput.value),
337+
);
338+
comboInput.addEventListener("blur", () => {
339+
setTimeout(() => (comboList.hidden = true), 150);
340+
});
300341

301342
async function loadEvents(): Promise<void> {
302343
const listing = parseContentsListing(await fetchJson(contentsApiUrl(repo)));
@@ -319,18 +360,11 @@ async function startEditor(repo: string): Promise<void> {
319360
);
320361
}
321362
}
322-
select.textContent = "";
323-
const placeholder = document.createElement("option");
324-
placeholder.value = "";
325-
placeholder.textContent =
326-
listed.length > 0 ? "Choose an event…" : "(no events found)";
327-
select.append(placeholder);
328-
listed.forEach(({ event }, index) => {
329-
const option = document.createElement("option");
330-
option.value = String(index);
331-
option.textContent = `${event.startDate ?? "????"}${event.name ?? event.id}`;
332-
select.append(option);
333-
});
363+
comboInput.placeholder =
364+
listed.length > 0
365+
? "Type to filter, or pick an event…"
366+
: "No events found in this repository";
367+
comboInput.disabled = listed.length === 0;
334368
refresh(); // collision checks were waiting for the listing
335369
}
336370

@@ -342,7 +376,7 @@ async function startEditor(repo: string): Promise<void> {
342376
)) {
343377
radio.addEventListener("input", () => {
344378
isNew = radio.value === "new";
345-
select.hidden = isNew;
379+
combo.hidden = isNew;
346380
touched = new Set();
347381
submitAttempted = false;
348382
if (isNew) {
@@ -357,8 +391,8 @@ async function startEditor(repo: string): Promise<void> {
357391
});
358392
}
359393

360-
select.addEventListener("input", () => {
361-
const chosen = listed[Number(select.value)];
394+
function pickEvent(index: number): void {
395+
const chosen = listed[index];
362396
if (!chosen) return;
363397
editSlug = chosen.slug;
364398
state = fromEventJson(chosen.event, chosen.slug ?? "");
@@ -367,7 +401,7 @@ async function startEditor(repo: string): Promise<void> {
367401
touched = new Set();
368402
submitAttempted = false;
369403
render(extraFieldsFor(chosen.event, profile));
370-
});
404+
}
371405

372406
// --- outputs --------------------------------------------------------------
373407
const fallback = el<HTMLElement>("fallback");
@@ -390,44 +424,21 @@ async function startEditor(repo: string): Promise<void> {
390424

391425
// --- review step ----------------------------------------------------------
392426
const review = el<HTMLDialogElement>("review");
393-
const reviewSummary = el<HTMLDListElement>("review-summary");
394427
const reviewJson = el<HTMLPreElement>("review-json");
395428

396-
function summaryRow(term: string, value: string): void {
397-
if (!value) return;
398-
const dt = document.createElement("dt");
399-
dt.textContent = term;
400-
const dd = document.createElement("dd");
401-
dd.textContent = value;
402-
reviewSummary.append(dt, dd);
403-
}
404-
405429
function openReview(): void {
406-
const event = toEventJson(state);
407-
reviewSummary.textContent = "";
408-
summaryRow("Event", state.name);
409-
summaryRow(
410-
"When",
411-
[
412-
state.startDate,
413-
!state.allDay && state.startTime,
414-
(state.endDate || state.endTime) && "→",
415-
state.endDate,
416-
!state.allDay && state.endTime,
417-
`(${state.timezone})`,
418-
]
419-
.filter(Boolean)
420-
.join(" "),
421-
);
422-
summaryRow(
423-
"Where",
424-
[state.venue, state.onlineUrl].filter(Boolean).join(" · "),
425-
);
426-
summaryRow(
427-
"File",
428-
`events/${state.slug}.json ${isNew ? "(new)" : "(update)"}`,
429-
);
430-
reviewJson.textContent = JSON.stringify(event, null, 2);
430+
const json = JSON.stringify(toEventJson(state), null, 2);
431+
reviewJson.textContent = "";
432+
for (const token of tokenizeJson(json)) {
433+
if (token.type === "plain") {
434+
reviewJson.append(token.text);
435+
} else {
436+
const span = document.createElement("span");
437+
span.className = `j-${token.type}`;
438+
span.textContent = token.text;
439+
reviewJson.append(span);
440+
}
441+
}
431442
review.showModal();
432443
}
433444

0 commit comments

Comments
 (0)