|
2 | 2 | // Generic additive list — the org card's repeated organ. Renders shaped |
3 | 3 | // entries (kind badge · name-or-host+path · date) with an inline ➕ form that |
4 | 4 | // hands the URL (+ optional kind, + optional name when nameable) to a |
5 | | - // caller-supplied add function. Entries are additive: no delete — canonical |
6 | | - // writes are append + dedup server-side. When the caller supplies onedit, |
7 | | - // an entry's kind/name become patchable in place (✎ or the kind badge) — |
8 | | - // fields on an entry are correctable; the entry itself is still additive. |
9 | | - // Busy/error states are localized to this list; a failed add or edit never |
10 | | - // disturbs the sibling lists. |
| 5 | + // caller-supplied add function. When the caller supplies onedit, an entry's |
| 6 | + // url/kind/name become patchable in place (✎ or the kind badge); onremove |
| 7 | + // adds the × with an inline confirm — the correction half of view-and-edit- |
| 8 | + // in-place, per context-v/specs/Entity-Card-Edit-And-Remove-Affordances.md. |
| 9 | + // The micro-buttons are hover/focus-revealed so rows rest quiet. |
| 10 | + // Busy/error states are localized to this list; a failed add, edit, or |
| 11 | + // remove never disturbs the sibling lists. |
11 | 12 |
|
12 | 13 | import type { ShapedLink } from './lib/types'; |
13 | 14 |
|
|
22 | 23 | onsearch, |
23 | 24 | oncrawl, |
24 | 25 | onedit, |
| 26 | + onremove, |
| 27 | + removenote, |
25 | 28 | entryaction, |
26 | 29 | }: { |
27 | 30 | title: string; |
|
35 | 38 | // Optional 🤖 — didi's crawl for this whole list (v1.2): header-level, |
36 | 39 | // because the list (not one entry) is the crawl's subject. |
37 | 40 | oncrawl?: () => void; |
38 | | - // Optional per-entry patch (kind/name matched by URL server-side) — |
39 | | - // presence turns on the in-place editor. |
40 | | - onedit?: (entry: Entry, patch: { kind?: string; name?: string }) => Promise<void>; |
| 41 | + // Optional per-entry patch (url/kind/name matched by current URL |
| 42 | + // server-side) — presence turns on the in-place editor. |
| 43 | + onedit?: (entry: Entry, patch: { url?: string; kind?: string; name?: string }) => Promise<void>; |
| 44 | + // Optional per-entry remove (matched by URL server-side) — presence |
| 45 | + // turns on the × with its inline confirm. |
| 46 | + onremove?: (entry: Entry) => Promise<void>; |
| 47 | + // Optional caller-supplied caution shown inside the remove confirm |
| 48 | + // (e.g. "this stream fed 3 corpus items — they stay"). |
| 49 | + removenote?: (entry: Entry) => string | null; |
41 | 50 | // Optional per-entry action (Phase 5 — "scan" on pulse streams). |
42 | 51 | entryaction?: { label: string; fn: (entry: Entry) => void }; |
43 | 52 | } = $props(); |
|
52 | 61 |
|
53 | 62 | // In-place editor — one row at a time, keyed by the entry's URL. |
54 | 63 | let editUrl = $state<string | null>(null); |
| 64 | + let editNewUrl = $state(''); |
55 | 65 | let editKind = $state(''); |
56 | 66 | let editName = $state(''); |
57 | 67 | let editBusy = $state(false); |
58 | 68 | let editError = $state<string | null>(null); |
59 | 69 |
|
| 70 | + // Remove confirm — one row at a time, keyed by the entry's URL. |
| 71 | + let removeUrl = $state<string | null>(null); |
| 72 | + let removeBusy = $state(false); |
| 73 | + let removeError = $state<string | null>(null); |
| 74 | +
|
| 75 | + async function commitRemove(entry: Entry) { |
| 76 | + if (!onremove) return; |
| 77 | + removeBusy = true; |
| 78 | + removeError = null; |
| 79 | + try { |
| 80 | + await onremove(entry); |
| 81 | + removeUrl = null; |
| 82 | + } catch (err) { |
| 83 | + removeError = err instanceof Error ? err.message : String(err); |
| 84 | + } finally { |
| 85 | + removeBusy = false; |
| 86 | + } |
| 87 | + } |
| 88 | +
|
60 | 89 | async function submit(e: SubmitEvent) { |
61 | 90 | e.preventDefault(); |
62 | 91 | const trimmed = url.trim(); |
|
80 | 109 |
|
81 | 110 | function startEdit(entry: Entry) { |
82 | 111 | editUrl = entry.url; |
| 112 | + editNewUrl = entry.url; |
83 | 113 | editKind = entry.kind; |
84 | 114 | editName = entry.name ?? ''; |
85 | 115 | editError = null; |
| 116 | + removeUrl = null; |
86 | 117 | } |
87 | 118 |
|
88 | 119 | function abortEdit() { |
|
93 | 124 | async function commitEdit(e: SubmitEvent, entry: Entry) { |
94 | 125 | e.preventDefault(); |
95 | 126 | if (!onedit) return; |
96 | | - const patch: { kind?: string; name?: string } = {}; |
| 127 | + const patch: { url?: string; kind?: string; name?: string } = {}; |
| 128 | + const u = editNewUrl.trim(); |
97 | 129 | const k = editKind.trim(); |
98 | 130 | const n = editName.trim(); |
| 131 | + if (u && u !== entry.url) patch.url = u; |
99 | 132 | if (k && k !== entry.kind) patch.kind = k; |
100 | 133 | if (n && n !== (entry.name ?? '')) patch.name = n; |
101 | | - if (!patch.kind && !patch.name) { |
| 134 | + if (!patch.url && !patch.kind && !patch.name) { |
102 | 135 | abortEdit(); |
103 | 136 | return; |
104 | 137 | } |
|
197 | 230 | <li class="ow-entry"> |
198 | 231 | {#if onedit && editUrl === e.url} |
199 | 232 | <form class="ow-add" onsubmit={(ev) => commitEdit(ev, e)}> |
| 233 | + <input |
| 234 | + class="ow-add-url" |
| 235 | + type="url" |
| 236 | + placeholder="https://…" |
| 237 | + bind:value={editNewUrl} |
| 238 | + onkeydown={onEditKey} |
| 239 | + disabled={editBusy} |
| 240 | + /> |
200 | 241 | <input |
201 | 242 | class="ow-add-kind" |
202 | 243 | type="text" |
|
237 | 278 | <span class="ow-kind">{e.kind}</span> |
238 | 279 | {/if} |
239 | 280 | <a class="ow-url" href={e.url} target="_blank" rel="noreferrer">{e.name ?? display(e.url)}</a> |
240 | | - {#if onedit} |
241 | | - <button |
242 | | - type="button" |
243 | | - class="ow-entry-action" |
244 | | - title="edit kind{nameable ? ' / name' : ''}" |
245 | | - onclick={() => startEdit(e)} |
246 | | - > |
247 | | - ✎ |
248 | | - </button> |
| 281 | + {#if onremove && removeUrl === e.url} |
| 282 | + <span class="ow-remove-confirm"> |
| 283 | + remove?{#if removenote?.(e)} <em class="ow-remove-note">{removenote(e)}</em>{/if} |
| 284 | + <button |
| 285 | + type="button" |
| 286 | + class="ow-add-go ow-remove-yes" |
| 287 | + disabled={removeBusy} |
| 288 | + onclick={() => commitRemove(e)} |
| 289 | + > |
| 290 | + {removeBusy ? '…' : 'yes'} |
| 291 | + </button> |
| 292 | + <button type="button" class="ow-add-go" disabled={removeBusy} onclick={() => (removeUrl = null)}> |
| 293 | + keep |
| 294 | + </button> |
| 295 | + </span> |
| 296 | + {:else} |
| 297 | + {#if onedit} |
| 298 | + <button |
| 299 | + type="button" |
| 300 | + class="ow-entry-action ow-micro" |
| 301 | + title="edit url / kind{nameable ? ' / name' : ''}" |
| 302 | + onclick={() => startEdit(e)} |
| 303 | + > |
| 304 | + ✎ |
| 305 | + </button> |
| 306 | + {/if} |
| 307 | + {#if onremove} |
| 308 | + <button |
| 309 | + type="button" |
| 310 | + class="ow-entry-action ow-micro" |
| 311 | + title="remove from {title}" |
| 312 | + onclick={() => (removeUrl = e.url)} |
| 313 | + > |
| 314 | + × |
| 315 | + </button> |
| 316 | + {/if} |
249 | 317 | {/if} |
250 | 318 | {#if entryaction} |
251 | 319 | <button type="button" class="ow-entry-action" onclick={() => entryaction.fn(e)}> |
252 | 320 | {entryaction.label} |
253 | 321 | </button> |
254 | 322 | {/if} |
255 | 323 | <span class="ow-date">{(e.added_at ?? '').slice(0, 10)}</span> |
| 324 | + {#if removeError && removeUrl === e.url}<div class="ow-error">{removeError}</div>{/if} |
256 | 325 | {/if} |
257 | 326 | </li> |
258 | 327 | {/each} |
|
272 | 341 | .ow-kind-editable:hover { |
273 | 342 | border-color: currentColor; |
274 | 343 | } |
| 344 | + /* ✎/× rest invisible so rows stay quiet; hover or keyboard focus reveals |
| 345 | + them (spec D5). entryaction buttons ("scan") stay always-visible. */ |
| 346 | + .ow-entry .ow-micro { |
| 347 | + opacity: 0; |
| 348 | + transition: opacity 0.1s ease; |
| 349 | + } |
| 350 | + .ow-entry:hover .ow-micro, |
| 351 | + .ow-entry:focus-within .ow-micro { |
| 352 | + opacity: 1; |
| 353 | + } |
| 354 | + .ow-remove-confirm { |
| 355 | + display: inline-flex; |
| 356 | + align-items: center; |
| 357 | + gap: 0.35rem; |
| 358 | + font-size: 0.72rem; |
| 359 | + color: var(--color-text-muted, #9aa0aa); |
| 360 | + } |
| 361 | + .ow-remove-note { |
| 362 | + font-style: italic; |
| 363 | + } |
| 364 | + .ow-remove-yes { |
| 365 | + border-color: var(--color-error-text, #f3a3a3); |
| 366 | + color: var(--color-error-text, #f3a3a3); |
| 367 | + } |
275 | 368 | </style> |
0 commit comments