benresnic commited on
Commit
5ae9214
·
verified ·
1 Parent(s): f727999

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +332 -60
app.py CHANGED
@@ -40,12 +40,30 @@ def _new_card() -> dict:
40
  return {
41
  "id": str(uuid4())[:12],
42
  "model": "",
 
43
  "how_it_works": "",
44
  "use_cases": "",
45
  "link": "",
46
  }
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def _normalize_state(obj: dict) -> dict:
50
  if not isinstance(obj, dict):
51
  obj = {}
@@ -68,10 +86,19 @@ def _normalize_state(obj: dict) -> dict:
68
  use_cases = c.get("use_cases", c.get("use", ""))
69
  link = c.get("link", c.get("resource_url", ""))
70
 
 
 
 
 
 
 
 
 
71
  normalized.append(
72
  {
73
  "id": str(c.get("id") or str(uuid4())[:12]),
74
  "model": "" if model is None else str(model),
 
75
  "how_it_works": "" if how_it_works is None else str(how_it_works),
76
  "use_cases": "" if use_cases is None else str(use_cases),
77
  "link": "" if link is None else str(link),
@@ -153,9 +180,13 @@ async def api_card_id(request: Request) -> Response:
153
  if not isinstance(payload, dict):
154
  return _json({"error": "Invalid payload"}, status_code=400)
155
 
156
- allowed = {"model", "how_it_works", "use_cases", "link"}
157
  for k in allowed:
158
- if k in payload:
 
 
 
 
159
  cards[idx][k] = "" if payload[k] is None else str(payload[k])
160
 
161
  state["version"] += 1
@@ -179,10 +210,14 @@ app_ui = ui.page_fluid(
179
  --cardBg:#ffffff;
180
  --muted:#6b778c;
181
  --headerBg:#f3f6fb;
 
 
 
 
182
  }
183
  body { background: #f3f6fb; }
184
 
185
- /* Top bar: ONLY + */
186
  .topbar{
187
  background: linear-gradient(0deg, var(--navy2), var(--navy));
188
  padding: 14px 18px;
@@ -191,7 +226,7 @@ app_ui = ui.page_fluid(
191
  display:flex;
192
  justify-content:flex-end;
193
  align-items:center;
194
- margin-bottom: 12px;
195
  }
196
  #add-btn{
197
  background: white;
@@ -205,7 +240,55 @@ app_ui = ui.page_fluid(
205
  cursor: pointer;
206
  box-shadow: 0 2px 8px rgba(0,0,0,.14);
207
  }
208
- #add-btn:hover{ filter: brightness(.97); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  .err{
211
  margin: 0 16px 10px 16px;
@@ -246,6 +329,7 @@ app_ui = ui.page_fluid(
246
  min-height: 0;
247
  }
248
 
 
249
  .card-header{
250
  background: var(--headerBg);
251
  display:flex;
@@ -256,10 +340,16 @@ app_ui = ui.page_fluid(
256
  border-bottom: 1px solid #eef2f8;
257
  flex: 0 0 auto;
258
  }
259
-
260
- /* Title input in header */
 
 
 
 
 
261
  .title-input{
262
- width: 100%;
 
263
  border: none;
264
  outline: none;
265
  background: transparent;
@@ -269,18 +359,51 @@ app_ui = ui.page_fluid(
269
  font-weight: 650;
270
  color: #1f2a44;
271
  }
272
- .title-input::placeholder{
273
- color: #7b879c;
274
- font-weight: 650;
 
 
275
  }
276
- /* When filled, make it look like a bold title */
277
- .card.has-title .title-input{
278
- font-weight: 900;
 
 
 
 
 
 
279
  }
280
- /* Optional subtle focus background so it feels editable */
281
- .title-input:focus{
282
- background: rgba(255,255,255,.8);
283
  border: 1px solid #d8e1ee;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
285
 
286
  .del-btn{
@@ -293,7 +416,6 @@ app_ui = ui.page_fluid(
293
  padding: 0 8px;
294
  flex: 0 0 auto;
295
  }
296
- .del-btn:hover{ color:#1c2b44; }
297
 
298
  .card-main{
299
  padding: 12px;
@@ -302,30 +424,38 @@ app_ui = ui.page_fluid(
302
  gap: 12px;
303
  flex: 1 1 auto;
304
  min-height: 0;
305
- overflow:hidden; /* no card scroll */
306
  }
307
 
308
- /* How fixed height (~3-4 lines) */
309
- textarea.ta-fixed{
310
- height: 92px;
311
- line-height: 1.25;
312
- resize: none;
313
- overflow: auto;
314
- }
315
-
316
- /* Use Cases grows to fill remaining space (kills the blank gap) */
317
- .grow{
318
  display:flex;
319
  flex-direction:column;
320
- flex: 1 1 auto;
321
  min-height: 0;
322
  }
323
- textarea.ta-grow{
324
- flex: 1 1 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  min-height: 0;
326
- line-height: 1.25;
327
  resize: none;
328
  overflow: auto;
 
329
  }
330
 
331
  .card-footer{
@@ -333,17 +463,7 @@ app_ui = ui.page_fluid(
333
  border-top: 1px solid #eef2f8;
334
  flex: 0 0 auto;
335
  }
336
-
337
- label{
338
- display:block;
339
- font-size: 12px;
340
- color: var(--muted);
341
- margin: 0 0 5px;
342
- font-weight: 750;
343
- letter-spacing: .2px;
344
- }
345
-
346
- input, textarea{
347
  width:100%;
348
  box-sizing:border-box;
349
  border: 1px solid #d8e1ee;
@@ -353,7 +473,6 @@ app_ui = ui.page_fluid(
353
  outline: none;
354
  background: white;
355
  }
356
-
357
  .resource-link{
358
  display:inline-block;
359
  margin-top: 8px;
@@ -417,6 +536,7 @@ app_ui = ui.page_fluid(
417
  """
418
  ),
419
  ui.tags.div({"class": "topbar"}, ui.tags.button("+", {"id": "add-btn", "type": "button"})),
 
420
  ui.tags.div({"id": "err", "class": "err"}),
421
  ui.tags.div({"id": "grid", "class": "cards-grid"}),
422
  ui.tags.script(
@@ -425,10 +545,21 @@ app_ui = ui.page_fluid(
425
  const grid = document.getElementById("grid");
426
  const addBtn = document.getElementById("add-btn");
427
  const errBox = document.getElementById("err");
 
428
 
429
  const state = { version: null, cards: [] };
430
  const saveTimers = new Map();
431
 
 
 
 
 
 
 
 
 
 
 
432
  function showErr(msg){
433
  if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; }
434
  errBox.style.display="block";
@@ -464,6 +595,69 @@ app_ui = ui.page_fluid(
464
  .replaceAll(">", ">");
465
  }
466
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  async function doDelete(cardId){
468
  const data = await api(`/api/cards/${encodeURIComponent(cardId)}`, { method: "DELETE" });
469
  state.version = data.version;
@@ -510,7 +704,6 @@ app_ui = ui.page_fluid(
510
  typed.addEventListener("input", updateEnabled);
511
 
512
  function close(){ backdrop.remove(); }
513
-
514
  cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
515
  backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
516
 
@@ -523,37 +716,73 @@ app_ui = ui.page_fluid(
523
  typed.focus();
524
  }
525
 
526
- function render(){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  if(state.cards.length === 0){
528
  grid.innerHTML = `<div class="empty"><b>No cards yet.</b><div>Click the + to add your first card.</div></div>`;
529
  return;
530
  }
531
 
 
 
 
 
 
 
 
532
  grid.innerHTML = "";
533
 
534
- state.cards.forEach((c) => {
535
  const card = document.createElement("div");
536
  card.className = "card";
537
  card.dataset.id = c.id;
538
 
539
- // mark title bold if filled
540
- if((c.model || "").trim().length > 0) card.classList.add("has-title");
 
 
 
 
 
 
 
 
 
 
 
541
 
542
  card.innerHTML = `
543
  <div class="card-header">
544
- <input class="title-input" type="text" value="${escapeHtml(c.model)}" placeholder="Model" />
 
 
 
545
  <button class="del-btn" type="button" title="Delete">×</button>
546
  </div>
547
 
548
  <div class="card-main">
549
- <div>
550
  <label>How it Works</label>
551
- <textarea class="how ta-fixed">${escapeHtml(c.how_it_works)}</textarea>
552
  </div>
553
 
554
- <div class="grow">
555
  <label>Use Cases</label>
556
- <textarea class="use ta-grow">${escapeHtml(c.use_cases)}</textarea>
557
  </div>
558
  </div>
559
 
@@ -571,6 +800,9 @@ app_ui = ui.page_fluid(
571
  const linkEl = card.querySelector(".link");
572
  const linkWrap = card.querySelector(".linkwrap");
573
 
 
 
 
574
  function updateLink(){
575
  const url = (linkEl.value || "").trim();
576
  linkWrap.innerHTML = url
@@ -578,18 +810,24 @@ app_ui = ui.page_fluid(
578
  : "";
579
  }
580
 
581
- function updateTitleStyle(){
582
  const has = (titleEl.value || "").trim().length > 0;
583
  card.classList.toggle("has-title", has);
 
 
 
 
584
  }
585
 
586
- async function saveNow(){
587
  const payload = {
588
  model: titleEl.value || "",
589
  how_it_works: howEl.value || "",
590
  use_cases: useEl.value || "",
591
  link: linkEl.value || "",
592
  };
 
 
593
  try{
594
  const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
595
  method: "PUT",
@@ -599,7 +837,9 @@ app_ui = ui.page_fluid(
599
  state.cards = data.cards;
600
  showErr("");
601
  updateLink();
602
- updateTitleStyle();
 
 
603
  }catch(e){
604
  showErr(String(e.message || e));
605
  }
@@ -607,10 +847,10 @@ app_ui = ui.page_fluid(
607
 
608
  function scheduleSave(){
609
  updateLink();
610
- updateTitleStyle();
611
  const existing = saveTimers.get(c.id);
612
  if(existing) clearTimeout(existing);
613
- saveTimers.set(c.id, setTimeout(saveNow, 350));
614
  }
615
 
616
  [titleEl, howEl, useEl, linkEl].forEach(el => el.addEventListener("input", scheduleSave));
@@ -621,11 +861,43 @@ app_ui = ui.page_fluid(
621
  openDeleteModal(c.id);
622
  });
623
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  updateLink();
 
625
  grid.appendChild(card);
626
  });
627
  }
628
 
 
 
 
 
 
629
  async function refresh(){
630
  try{
631
  const active = document.activeElement;
 
40
  return {
41
  "id": str(uuid4())[:12],
42
  "model": "",
43
+ "tags": [], # NEW
44
  "how_it_works": "",
45
  "use_cases": "",
46
  "link": "",
47
  }
48
 
49
 
50
+ def _normalize_tags(x) -> list[str]:
51
+ if x is None:
52
+ return []
53
+ if isinstance(x, str):
54
+ x = [t.strip() for t in x.split(",")]
55
+ if not isinstance(x, list):
56
+ return []
57
+ out = []
58
+ for t in x:
59
+ if t is None:
60
+ continue
61
+ s = str(t).strip()
62
+ if s and s not in out:
63
+ out.append(s)
64
+ return out
65
+
66
+
67
  def _normalize_state(obj: dict) -> dict:
68
  if not isinstance(obj, dict):
69
  obj = {}
 
86
  use_cases = c.get("use_cases", c.get("use", ""))
87
  link = c.get("link", c.get("resource_url", ""))
88
 
89
+ # Back-compat: if someone stored "categories"/"tag"/etc.
90
+ tags = (
91
+ c.get("tags")
92
+ if "tags" in c
93
+ else c.get("categories", c.get("tag", c.get("model_tags", [])))
94
+ )
95
+ tags = _normalize_tags(tags)
96
+
97
  normalized.append(
98
  {
99
  "id": str(c.get("id") or str(uuid4())[:12]),
100
  "model": "" if model is None else str(model),
101
+ "tags": tags,
102
  "how_it_works": "" if how_it_works is None else str(how_it_works),
103
  "use_cases": "" if use_cases is None else str(use_cases),
104
  "link": "" if link is None else str(link),
 
180
  if not isinstance(payload, dict):
181
  return _json({"error": "Invalid payload"}, status_code=400)
182
 
183
+ allowed = {"model", "tags", "how_it_works", "use_cases", "link"}
184
  for k in allowed:
185
+ if k not in payload:
186
+ continue
187
+ if k == "tags":
188
+ cards[idx]["tags"] = _normalize_tags(payload.get("tags"))
189
+ else:
190
  cards[idx][k] = "" if payload[k] is None else str(payload[k])
191
 
192
  state["version"] += 1
 
210
  --cardBg:#ffffff;
211
  --muted:#6b778c;
212
  --headerBg:#f3f6fb;
213
+ --chipBg:#eef2f8;
214
+ --chipText:#1f2a44;
215
+ --chipActiveBg:#1f3a66;
216
+ --chipActiveText:#ffffff;
217
  }
218
  body { background: #f3f6fb; }
219
 
220
+ /* Top bar */
221
  .topbar{
222
  background: linear-gradient(0deg, var(--navy2), var(--navy));
223
  padding: 14px 18px;
 
226
  display:flex;
227
  justify-content:flex-end;
228
  align-items:center;
229
+ margin-bottom: 10px;
230
  }
231
  #add-btn{
232
  background: white;
 
240
  cursor: pointer;
241
  box-shadow: 0 2px 8px rgba(0,0,0,.14);
242
  }
243
+
244
+ /* Filters row */
245
+ .filters{
246
+ display:flex;
247
+ flex-wrap:wrap;
248
+ gap: 10px;
249
+ padding: 0 16px 12px 16px;
250
+ align-items:center;
251
+ }
252
+ .filters .label{
253
+ color: #1f2a44;
254
+ font-weight: 850;
255
+ font-size: 13px;
256
+ margin-right: 4px;
257
+ }
258
+ .chip{
259
+ border: 1px solid #d8e1ee;
260
+ background: var(--chipBg);
261
+ color: var(--chipText);
262
+ border-radius: 999px;
263
+ padding: 7px 10px;
264
+ font-size: 12px;
265
+ font-weight: 800;
266
+ cursor: pointer;
267
+ user-select:none;
268
+ display:inline-flex;
269
+ align-items:center;
270
+ gap: 6px;
271
+ }
272
+ .chip.active{
273
+ background: var(--chipActiveBg);
274
+ color: var(--chipActiveText);
275
+ border-color: rgba(0,0,0,.0);
276
+ }
277
+ .chip .x{
278
+ font-weight: 950;
279
+ opacity: .75;
280
+ }
281
+ .chip:hover{ filter: brightness(.98); }
282
+
283
+ .btn-clear{
284
+ margin-left: 6px;
285
+ border-radius: 10px;
286
+ padding: 7px 10px;
287
+ font-weight: 900;
288
+ border: 1px solid #d8e1ee;
289
+ background: #fff;
290
+ cursor:pointer;
291
+ }
292
 
293
  .err{
294
  margin: 0 16px 10px 16px;
 
329
  min-height: 0;
330
  }
331
 
332
+ /* Header with title + tags + delete */
333
  .card-header{
334
  background: var(--headerBg);
335
  display:flex;
 
340
  border-bottom: 1px solid #eef2f8;
341
  flex: 0 0 auto;
342
  }
343
+ .header-left{
344
+ display:flex;
345
+ align-items:center;
346
+ gap: 10px;
347
+ flex: 1 1 auto;
348
+ min-width: 0;
349
+ }
350
  .title-input{
351
+ flex: 1 1 auto;
352
+ min-width: 140px;
353
  border: none;
354
  outline: none;
355
  background: transparent;
 
359
  font-weight: 650;
360
  color: #1f2a44;
361
  }
362
+ .title-input::placeholder{ color: #7b879c; font-weight: 650; }
363
+ .card.has-title .title-input{ font-weight: 950; }
364
+ .title-input:focus{
365
+ background: rgba(255,255,255,.85);
366
+ border: 1px solid #d8e1ee;
367
  }
368
+
369
+ .tag-bar{
370
+ display:flex;
371
+ gap: 6px;
372
+ align-items:center;
373
+ flex: 0 0 auto;
374
+ max-width: 48%;
375
+ overflow:auto;
376
+ padding-bottom: 2px;
377
  }
378
+ .tag-chip{
 
 
379
  border: 1px solid #d8e1ee;
380
+ background: #fff;
381
+ color: #1f2a44;
382
+ border-radius: 999px;
383
+ padding: 6px 9px;
384
+ font-size: 11px;
385
+ font-weight: 850;
386
+ display:inline-flex;
387
+ align-items:center;
388
+ gap: 6px;
389
+ cursor:pointer;
390
+ white-space: nowrap;
391
+ }
392
+ .tag-chip .rm{
393
+ font-weight: 950;
394
+ opacity: .6;
395
+ }
396
+ .tag-chip:hover{ filter: brightness(.98); }
397
+
398
+ .tag-add{
399
+ border: 1px dashed #cfd9ea;
400
+ background: transparent;
401
+ border-radius: 999px;
402
+ padding: 6px 10px;
403
+ font-size: 11px;
404
+ font-weight: 900;
405
+ cursor:pointer;
406
+ white-space:nowrap;
407
  }
408
 
409
  .del-btn{
 
416
  padding: 0 8px;
417
  flex: 0 0 auto;
418
  }
 
419
 
420
  .card-main{
421
  padding: 12px;
 
424
  gap: 12px;
425
  flex: 1 1 auto;
426
  min-height: 0;
427
+ overflow:hidden;
428
  }
429
 
430
+ /* Equal-height panels */
431
+ .panel{
 
 
 
 
 
 
 
 
432
  display:flex;
433
  flex-direction:column;
434
+ flex: 1 1 0;
435
  min-height: 0;
436
  }
437
+ label{
438
+ display:block;
439
+ font-size: 12px;
440
+ color: var(--muted);
441
+ margin: 0 0 5px;
442
+ font-weight: 750;
443
+ letter-spacing: .2px;
444
+ }
445
+ textarea{
446
+ width:100%;
447
+ box-sizing:border-box;
448
+ border: 1px solid #d8e1ee;
449
+ border-radius: 12px;
450
+ padding: 10px 12px;
451
+ font-size: 14px;
452
+ outline: none;
453
+ background: white;
454
+ flex: 1 1 0;
455
  min-height: 0;
 
456
  resize: none;
457
  overflow: auto;
458
+ line-height: 1.25;
459
  }
460
 
461
  .card-footer{
 
463
  border-top: 1px solid #eef2f8;
464
  flex: 0 0 auto;
465
  }
466
+ input{
 
 
 
 
 
 
 
 
 
 
467
  width:100%;
468
  box-sizing:border-box;
469
  border: 1px solid #d8e1ee;
 
473
  outline: none;
474
  background: white;
475
  }
 
476
  .resource-link{
477
  display:inline-block;
478
  margin-top: 8px;
 
536
  """
537
  ),
538
  ui.tags.div({"class": "topbar"}, ui.tags.button("+", {"id": "add-btn", "type": "button"})),
539
+ ui.tags.div({"id": "filters", "class": "filters"}),
540
  ui.tags.div({"id": "err", "class": "err"}),
541
  ui.tags.div({"id": "grid", "class": "cards-grid"}),
542
  ui.tags.script(
 
545
  const grid = document.getElementById("grid");
546
  const addBtn = document.getElementById("add-btn");
547
  const errBox = document.getElementById("err");
548
+ const filtersEl = document.getElementById("filters");
549
 
550
  const state = { version: null, cards: [] };
551
  const saveTimers = new Map();
552
 
553
+ // Tag suggestions (you can add more; custom tags are allowed too)
554
+ const DEFAULT_TAGS = [
555
+ "Regression", "Classification", "Time Series", "Causal",
556
+ "Tree-based", "Linear", "Bayesian", "Deep Learning",
557
+ "Unsupervised", "Dimensionality Reduction", "NLP", "Computer Vision",
558
+ "Ranking", "Survival"
559
+ ];
560
+
561
+ const activeFilters = new Set();
562
+
563
  function showErr(msg){
564
  if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; }
565
  errBox.style.display="block";
 
595
  .replaceAll(">", "&gt;");
596
  }
597
 
598
+ function uniq(arr){
599
+ const out = [];
600
+ const seen = new Set();
601
+ for(const x of arr){
602
+ const s = String(x || "").trim();
603
+ if(!s) continue;
604
+ if(seen.has(s)) continue;
605
+ seen.add(s);
606
+ out.push(s);
607
+ }
608
+ return out;
609
+ }
610
+
611
+ function allKnownTags(){
612
+ const fromCards = [];
613
+ for(const c of state.cards){
614
+ const tags = Array.isArray(c.tags) ? c.tags : [];
615
+ for(const t of tags) fromCards.push(t);
616
+ }
617
+ return uniq([...DEFAULT_TAGS, ...fromCards]).sort((a,b)=>a.localeCompare(b));
618
+ }
619
+
620
+ function cardMatchesFilters(card){
621
+ if(activeFilters.size === 0) return true;
622
+ const tags = new Set(Array.isArray(card.tags) ? card.tags : []);
623
+ for(const f of activeFilters){
624
+ if(tags.has(f)) return true; // OR logic
625
+ }
626
+ return false;
627
+ }
628
+
629
+ function renderFilters(){
630
+ const tags = allKnownTags();
631
+
632
+ const chips = tags.map(t => {
633
+ const active = activeFilters.has(t) ? "active" : "";
634
+ return `<span class="chip ${active}" data-tag="${escapeAttr(t)}">${escapeHtml(t)}</span>`;
635
+ }).join("");
636
+
637
+ const any = activeFilters.size > 0;
638
+ filtersEl.innerHTML = `
639
+ <span class="label">Filters:</span>
640
+ ${chips}
641
+ <button class="btn-clear" id="clearFilters" type="button" ${any ? "" : "disabled"}>Clear</button>
642
+ `;
643
+
644
+ filtersEl.querySelectorAll(".chip").forEach(chip => {
645
+ chip.addEventListener("click", () => {
646
+ const t = chip.getAttribute("data-tag");
647
+ if(activeFilters.has(t)) activeFilters.delete(t);
648
+ else activeFilters.add(t);
649
+ renderFilters();
650
+ renderCardsOnly();
651
+ });
652
+ });
653
+
654
+ filtersEl.querySelector("#clearFilters").addEventListener("click", () => {
655
+ activeFilters.clear();
656
+ renderFilters();
657
+ renderCardsOnly();
658
+ });
659
+ }
660
+
661
  async function doDelete(cardId){
662
  const data = await api(`/api/cards/${encodeURIComponent(cardId)}`, { method: "DELETE" });
663
  state.version = data.version;
 
704
  typed.addEventListener("input", updateEnabled);
705
 
706
  function close(){ backdrop.remove(); }
 
707
  cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
708
  backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
709
 
 
716
  typed.focus();
717
  }
718
 
719
+ function tagPrompt(existingTags){
720
+ // simple add flow: choose from list or custom
721
+ const known = allKnownTags();
722
+ const remaining = known.filter(t => !existingTags.includes(t));
723
+ const menu = remaining.slice(0, 12); // keep prompt readable
724
+ const promptText =
725
+ "Add a tag.\n\n" +
726
+ (menu.length ? ("Suggestions: " + menu.join(", ") + "\n\n") : "") +
727
+ "Type a tag name (exactly as you want it):";
728
+ const val = (prompt(promptText) || "").trim();
729
+ if(!val) return null;
730
+ return val;
731
+ }
732
+
733
+ function renderCardsOnly(){
734
+ // renders only the grid (filters already rendered)
735
  if(state.cards.length === 0){
736
  grid.innerHTML = `<div class="empty"><b>No cards yet.</b><div>Click the + to add your first card.</div></div>`;
737
  return;
738
  }
739
 
740
+ const visible = state.cards.filter(cardMatchesFilters);
741
+
742
+ if(visible.length === 0){
743
+ grid.innerHTML = `<div class="empty"><b>No matches.</b><div>Clear filters to show all cards.</div></div>`;
744
+ return;
745
+ }
746
+
747
  grid.innerHTML = "";
748
 
749
+ visible.forEach((c) => {
750
  const card = document.createElement("div");
751
  card.className = "card";
752
  card.dataset.id = c.id;
753
 
754
+ const titleFilled = (c.model || "").trim().length > 0;
755
+ if(titleFilled) card.classList.add("has-title");
756
+
757
+ const tags = Array.isArray(c.tags) ? c.tags : [];
758
+
759
+ const tagHtml = titleFilled
760
+ ? `
761
+ <div class="tag-bar">
762
+ ${tags.map(t => `<span class="tag-chip" data-tag="${escapeAttr(t)}">${escapeHtml(t)} <span class="rm">×</span></span>`).join("")}
763
+ <button class="tag-add" type="button">+ Tag</button>
764
+ </div>
765
+ `
766
+ : `<div class="tag-bar" style="display:none;"></div>`;
767
 
768
  card.innerHTML = `
769
  <div class="card-header">
770
+ <div class="header-left">
771
+ <input class="title-input" type="text" value="${escapeHtml(c.model)}" placeholder="Model" />
772
+ ${tagHtml}
773
+ </div>
774
  <button class="del-btn" type="button" title="Delete">×</button>
775
  </div>
776
 
777
  <div class="card-main">
778
+ <div class="panel">
779
  <label>How it Works</label>
780
+ <textarea class="how">${escapeHtml(c.how_it_works)}</textarea>
781
  </div>
782
 
783
+ <div class="panel">
784
  <label>Use Cases</label>
785
+ <textarea class="use">${escapeHtml(c.use_cases)}</textarea>
786
  </div>
787
  </div>
788
 
 
800
  const linkEl = card.querySelector(".link");
801
  const linkWrap = card.querySelector(".linkwrap");
802
 
803
+ const tagBar = card.querySelector(".tag-bar");
804
+ const tagAddBtn = card.querySelector(".tag-add");
805
+
806
  function updateLink(){
807
  const url = (linkEl.value || "").trim();
808
  linkWrap.innerHTML = url
 
810
  : "";
811
  }
812
 
813
+ function updateTitleStyleAndTagBar(){
814
  const has = (titleEl.value || "").trim().length > 0;
815
  card.classList.toggle("has-title", has);
816
+ // show/hide tag bar
817
+ if(tagBar){
818
+ tagBar.style.display = has ? "flex" : "none";
819
+ }
820
  }
821
 
822
+ async function saveNow(extraPayload = null){
823
  const payload = {
824
  model: titleEl.value || "",
825
  how_it_works: howEl.value || "",
826
  use_cases: useEl.value || "",
827
  link: linkEl.value || "",
828
  };
829
+ if(extraPayload) Object.assign(payload, extraPayload);
830
+
831
  try{
832
  const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
833
  method: "PUT",
 
837
  state.cards = data.cards;
838
  showErr("");
839
  updateLink();
840
+ updateTitleStyleAndTagBar();
841
+ renderFilters(); // tags may have changed
842
+ renderCardsOnly(); // so filtering reflects new tags
843
  }catch(e){
844
  showErr(String(e.message || e));
845
  }
 
847
 
848
  function scheduleSave(){
849
  updateLink();
850
+ updateTitleStyleAndTagBar();
851
  const existing = saveTimers.get(c.id);
852
  if(existing) clearTimeout(existing);
853
+ saveTimers.set(c.id, setTimeout(() => saveNow(), 350));
854
  }
855
 
856
  [titleEl, howEl, useEl, linkEl].forEach(el => el.addEventListener("input", scheduleSave));
 
861
  openDeleteModal(c.id);
862
  });
863
 
864
+ // Tag interactions (only if title is filled)
865
+ if(tagAddBtn){
866
+ tagAddBtn.addEventListener("click", async (e) => {
867
+ e.preventDefault();
868
+ e.stopPropagation();
869
+ const current = Array.isArray(c.tags) ? c.tags.slice() : [];
870
+ const t = tagPrompt(current);
871
+ if(!t) return;
872
+ if(!current.includes(t)) current.push(t);
873
+ await saveNow({ tags: current });
874
+ });
875
+ }
876
+
877
+ if(tagBar){
878
+ tagBar.querySelectorAll(".tag-chip").forEach(ch => {
879
+ ch.addEventListener("click", async (e) => {
880
+ e.preventDefault();
881
+ e.stopPropagation();
882
+ const t = ch.getAttribute("data-tag");
883
+ const current = Array.isArray(c.tags) ? c.tags.slice() : [];
884
+ const next = current.filter(x => x !== t);
885
+ await saveNow({ tags: next });
886
+ });
887
+ });
888
+ }
889
+
890
  updateLink();
891
+ updateTitleStyleAndTagBar();
892
  grid.appendChild(card);
893
  });
894
  }
895
 
896
+ function render(){
897
+ renderFilters();
898
+ renderCardsOnly();
899
+ }
900
+
901
  async function refresh(){
902
  try{
903
  const active = document.activeElement;