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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +483 -240
app.py CHANGED
@@ -4,6 +4,8 @@ from __future__ import annotations
4
 
5
  import asyncio
6
  import json
 
 
7
  from pathlib import Path
8
  from uuid import uuid4
9
 
@@ -35,12 +37,20 @@ STATE_DIR = _pick_state_dir()
35
  STATE_PATH = STATE_DIR / "cards_state.json"
36
  STATE_LOCK = asyncio.Lock()
37
 
 
 
 
 
 
 
 
 
38
 
39
  def _new_card() -> dict:
40
  return {
41
  "id": str(uuid4())[:12],
42
  "model": "",
43
- "tags": [], # NEW
44
  "how_it_works": "",
45
  "use_cases": "",
46
  "link": "",
@@ -86,7 +96,6 @@ def _normalize_state(obj: dict) -> dict:
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
@@ -138,6 +147,31 @@ def _json(data: dict, status_code: int = 200) -> JSONResponse:
138
  return JSONResponse(data, status_code=status_code, headers={"Cache-Control": "no-store"})
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  # -----------------------------------------------------------------------------
142
  # API endpoints
143
  # -----------------------------------------------------------------------------
@@ -147,6 +181,10 @@ async def api_cards(request: Request) -> Response:
147
  return _json(await get_state())
148
 
149
  if request.method == "POST":
 
 
 
 
150
  state = await get_state()
151
  state["cards"].append(_new_card())
152
  state["version"] += 1
@@ -166,12 +204,18 @@ async def api_card_id(request: Request) -> Response:
166
  return _json({"error": "Card not found"}, status_code=404)
167
 
168
  if request.method == "DELETE":
 
 
 
 
169
  cards.pop(idx)
170
  state["version"] += 1
171
  await set_state(state)
172
  return _json(state)
173
 
174
  if request.method == "PUT":
 
 
175
  try:
176
  payload = await request.json()
177
  except Exception:
@@ -197,7 +241,7 @@ async def api_card_id(request: Request) -> Response:
197
 
198
 
199
  # -----------------------------------------------------------------------------
200
- # Shiny UI
201
  # -----------------------------------------------------------------------------
202
 
203
  app_ui = ui.page_fluid(
@@ -210,85 +254,92 @@ app_ui = ui.page_fluid(
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;
224
  border-radius: 0 0 14px 14px;
225
  box-shadow: 0 2px 10px rgba(0,0,0,.12);
226
  display:flex;
227
- justify-content:flex-end;
228
  align-items:center;
 
229
  margin-bottom: 10px;
230
  }
231
- #add-btn{
232
- background: white;
233
- color: var(--navy);
234
- border: 1px solid rgba(0,0,0,.08);
 
 
 
 
235
  border-radius: 12px;
236
- padding: 12px 22px;
237
  font-weight: 900;
238
- font-size: 22px;
239
- line-height: 1;
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;
@@ -371,10 +422,12 @@ app_ui = ui.page_fluid(
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;
@@ -393,8 +446,6 @@ app_ui = ui.page_fluid(
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;
@@ -488,7 +539,63 @@ app_ui = ui.page_fluid(
488
  color: #41516b;
489
  }
490
 
491
- /* Delete modal (avoid Bootstrap conflicts) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  .del-backdrop{
493
  position: fixed;
494
  inset: 0;
@@ -535,8 +642,16 @@ app_ui = ui.page_fluid(
535
  .btn[disabled]{ opacity: .55; cursor: not-allowed; }
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,20 +660,25 @@ app_ui = ui.page_fluid(
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; }
@@ -566,19 +686,6 @@ app_ui = ui.page_fluid(
566
  errBox.textContent = msg;
567
  }
568
 
569
- async function api(path, opts={}){
570
- const res = await fetch(path, {
571
- headers: { "Content-Type": "application/json" },
572
- cache: "no-store",
573
- ...opts
574
- });
575
- const data = await res.json().catch(() => ({}));
576
- if(!res.ok){
577
- throw new Error(data?.error || ("HTTP " + res.status));
578
- }
579
- return data;
580
- }
581
-
582
  function escapeHtml(str){
583
  return String(str ?? "")
584
  .replaceAll("&", "&")
@@ -595,78 +702,156 @@ app_ui = ui.page_fluid(
595
  .replaceAll(">", ">");
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;
664
  state.cards = data.cards;
665
  showErr("");
 
666
  render();
667
  }
668
 
669
  function openDeleteModal(cardId){
 
 
670
  const backdrop = document.createElement("div");
671
  backdrop.className = "del-backdrop";
672
  backdrop.innerHTML = `
@@ -679,8 +864,8 @@ app_ui = ui.page_fluid(
679
  <label><input type="checkbox" id="chk2" /> I confirm I want to delete this card</label>
680
  </div>
681
 
682
- <label style="margin:8px 0 4px; color:#1f2a44; font-weight:800;">Type DELETE to confirm</label>
683
- <input type="text" id="typed" value="" />
684
 
685
  <div class="del-actions">
686
  <button class="btn" id="cancel" type="button">Cancel</button>
@@ -692,16 +877,16 @@ app_ui = ui.page_fluid(
692
 
693
  const chk1 = backdrop.querySelector("#chk1");
694
  const chk2 = backdrop.querySelector("#chk2");
695
- const typed = backdrop.querySelector("#typed");
696
  const cancel = backdrop.querySelector("#cancel");
697
  const confirmBtn = backdrop.querySelector("#confirm");
698
 
699
  function updateEnabled(){
700
- confirmBtn.disabled = !(chk1.checked && chk2.checked && (typed.value || "") === "DELETE");
701
  }
702
  chk1.addEventListener("change", updateEnabled);
703
  chk2.addEventListener("change", updateEnabled);
704
- typed.addEventListener("input", updateEnabled);
705
 
706
  function close(){ backdrop.remove(); }
707
  cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
@@ -709,38 +894,33 @@ app_ui = ui.page_fluid(
709
 
710
  confirmBtn.addEventListener("click", async (e) => {
711
  e.preventDefault();
712
- try { await doDelete(cardId); } catch(err) { showErr(String(err.message || err)); }
 
 
 
 
 
 
 
 
713
  close();
714
  });
715
 
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
 
@@ -751,25 +931,19 @@ app_ui = ui.page_fluid(
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>
@@ -779,7 +953,6 @@ app_ui = ui.page_fluid(
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>
@@ -793,14 +966,14 @@ app_ui = ui.page_fluid(
793
  </div>
794
  `;
795
 
796
- const delBtn = card.querySelector(".del-btn");
797
- const titleEl = card.querySelector(".title-input");
798
- const howEl = card.querySelector(".how");
799
- const useEl = card.querySelector(".use");
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(){
@@ -810,23 +983,38 @@ app_ui = ui.page_fluid(
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)}`, {
@@ -837,67 +1025,129 @@ app_ui = ui.page_fluid(
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
  }
846
  }
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));
 
 
857
 
858
- delBtn.addEventListener("click", (e) => {
859
- e.preventDefault();
860
- e.stopPropagation();
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;
@@ -909,6 +1159,7 @@ app_ui = ui.page_fluid(
909
  state.version = data.version;
910
  state.cards = data.cards;
911
  showErr("");
 
912
  render();
913
  }
914
  }catch(e){
@@ -916,20 +1167,12 @@ app_ui = ui.page_fluid(
916
  }
917
  }
918
 
919
- addBtn.addEventListener("click", async () => {
920
- try{
921
- const data = await api("/api/cards", { method: "POST" });
922
- state.version = data.version;
923
- state.cards = data.cards;
924
- showErr("");
925
- render();
926
- }catch(e){
927
- showErr(String(e.message || e));
928
- }
929
- });
930
-
931
- refresh().then(render);
932
- setInterval(refresh, 2500);
933
  })();
934
  """
935
  ),
 
4
 
5
  import asyncio
6
  import json
7
+ import os
8
+ import hmac
9
  from pathlib import Path
10
  from uuid import uuid4
11
 
 
37
  STATE_PATH = STATE_DIR / "cards_state.json"
38
  STATE_LOCK = asyncio.Lock()
39
 
40
+ # Secret password from env (HF Secrets)
41
+ SERVER_PASSWORD = (
42
+ os.environ.get("password")
43
+ or os.environ.get("PASSWORD")
44
+ or os.environ.get("APP_PASSWORD")
45
+ or ""
46
+ ).strip()
47
+
48
 
49
  def _new_card() -> dict:
50
  return {
51
  "id": str(uuid4())[:12],
52
  "model": "",
53
+ "tags": [],
54
  "how_it_works": "",
55
  "use_cases": "",
56
  "link": "",
 
96
  use_cases = c.get("use_cases", c.get("use", ""))
97
  link = c.get("link", c.get("resource_url", ""))
98
 
 
99
  tags = (
100
  c.get("tags")
101
  if "tags" in c
 
147
  return JSONResponse(data, status_code=status_code, headers={"Cache-Control": "no-store"})
148
 
149
 
150
+ def _unauthorized() -> JSONResponse:
151
+ return _json({"error": "Unauthorized"}, status_code=401)
152
+
153
+
154
+ def _check_password_from_request(request: Request, payload: dict | None = None) -> bool:
155
+ """
156
+ Accept password via:
157
+ - Header: X-Password
158
+ - Query: ?password=...
159
+ - JSON body field: {"password": "..."} (optional)
160
+ """
161
+ if not SERVER_PASSWORD:
162
+ # If you forgot to set the secret, block add/delete so you notice.
163
+ return False
164
+
165
+ provided = request.headers.get("X-Password") or request.query_params.get("password")
166
+ if not provided and isinstance(payload, dict):
167
+ provided = payload.get("password")
168
+
169
+ if not isinstance(provided, str):
170
+ return False
171
+
172
+ return hmac.compare_digest(provided.strip(), SERVER_PASSWORD)
173
+
174
+
175
  # -----------------------------------------------------------------------------
176
  # API endpoints
177
  # -----------------------------------------------------------------------------
 
181
  return _json(await get_state())
182
 
183
  if request.method == "POST":
184
+ # ADD requires password
185
+ if not _check_password_from_request(request):
186
+ return _unauthorized()
187
+
188
  state = await get_state()
189
  state["cards"].append(_new_card())
190
  state["version"] += 1
 
204
  return _json({"error": "Card not found"}, status_code=404)
205
 
206
  if request.method == "DELETE":
207
+ # DELETE requires password
208
+ if not _check_password_from_request(request):
209
+ return _unauthorized()
210
+
211
  cards.pop(idx)
212
  state["version"] += 1
213
  await set_state(state)
214
  return _json(state)
215
 
216
  if request.method == "PUT":
217
+ # Edits do NOT require password (as requested: only add/delete).
218
+ # If you later want to lock edits too, I can flip this on.
219
  try:
220
  payload = await request.json()
221
  except Exception:
 
241
 
242
 
243
  # -----------------------------------------------------------------------------
244
+ # Shiny UI (client uses fetch() to call the API above)
245
  # -----------------------------------------------------------------------------
246
 
247
  app_ui = ui.page_fluid(
 
254
  --cardBg:#ffffff;
255
  --muted:#6b778c;
256
  --headerBg:#f3f6fb;
 
 
 
 
257
  }
258
  body { background: #f3f6fb; }
259
 
260
+ /* Top bar: filters left (compact), + right */
261
  .topbar{
262
  background: linear-gradient(0deg, var(--navy2), var(--navy));
263
+ padding: 12px 14px;
264
  border-radius: 0 0 14px 14px;
265
  box-shadow: 0 2px 10px rgba(0,0,0,.12);
266
  display:flex;
267
+ justify-content:space-between;
268
  align-items:center;
269
+ gap: 12px;
270
  margin-bottom: 10px;
271
  }
272
+ .top-left{
273
+ display:flex;
274
+ align-items:center;
275
+ gap: 10px;
276
+ min-width: 0;
277
+ flex: 1 1 auto;
278
+ }
279
+ #filter-btn, #clear-filters{
280
  border-radius: 12px;
281
+ padding: 9px 12px;
282
  font-weight: 900;
283
+ font-size: 13px;
284
+ cursor:pointer;
285
+ white-space: nowrap;
286
+ }
287
+ #filter-btn{
288
+ background: rgba(255,255,255,.92);
289
+ color: var(--navy);
290
+ border: 1px solid rgba(0,0,0,.08);
291
+ }
292
+ #clear-filters{
293
+ background: rgba(255,255,255,.12);
294
+ color: #fff;
295
+ border: 1px solid rgba(255,255,255,.25);
296
  }
297
 
298
+ .active-filters{
 
299
  display:flex;
300
+ gap: 8px;
 
 
301
  align-items:center;
302
+ overflow-x:auto;
303
+ padding: 2px 4px;
304
+ min-width: 0;
305
+ flex: 1 1 auto;
306
  }
307
+ .active-filters::-webkit-scrollbar{ display:none; }
308
+
309
+ .pill{
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  display:inline-flex;
311
  align-items:center;
312
  gap: 6px;
313
+ padding: 6px 10px;
314
+ border-radius: 999px;
315
+ font-size: 12px;
316
+ font-weight: 850;
317
+ border: 1px solid rgba(255,255,255,.25);
318
+ background: rgba(255,255,255,.12);
319
+ color: #fff;
320
+ white-space: nowrap;
321
+ user-select:none;
322
  }
323
+ .pill .x{
324
+ cursor:pointer;
325
+ font-weight: 900;
326
+ opacity: .9;
 
 
 
 
327
  }
 
328
 
329
+ #add-btn{
330
+ background: white;
331
+ color: var(--navy);
332
+ border: 1px solid rgba(0,0,0,.08);
333
+ border-radius: 12px;
334
+ padding: 12px 20px;
335
+ font-weight: 950;
336
+ font-size: 22px;
337
+ line-height: 1;
338
+ cursor: pointer;
339
+ box-shadow: 0 2px 8px rgba(0,0,0,.14);
340
+ flex: 0 0 auto;
341
  }
342
+ #add-btn:hover{ filter: brightness(.97); }
343
 
344
  .err{
345
  margin: 0 16px 10px 16px;
 
422
  gap: 6px;
423
  align-items:center;
424
  flex: 0 0 auto;
425
+ max-width: 52%;
426
  overflow:auto;
427
  padding-bottom: 2px;
428
  }
429
+ .tag-bar::-webkit-scrollbar{ display:none; }
430
+
431
  .tag-chip{
432
  border: 1px solid #d8e1ee;
433
  background: #fff;
 
446
  font-weight: 950;
447
  opacity: .6;
448
  }
 
 
449
  .tag-add{
450
  border: 1px dashed #cfd9ea;
451
  background: transparent;
 
539
  color: #41516b;
540
  }
541
 
542
+ /* Popover (filters + tag add) */
543
+ .pop{
544
+ position: fixed;
545
+ background: white;
546
+ border: 1px solid #d8e1ee;
547
+ border-radius: 14px;
548
+ box-shadow: 0 10px 30px rgba(0,0,0,.18);
549
+ padding: 10px;
550
+ z-index: 999999;
551
+ width: 320px;
552
+ max-width: calc(100vw - 24px);
553
+ }
554
+ .pop h4{
555
+ margin: 2px 0 8px 0;
556
+ font-size: 13px;
557
+ font-weight: 950;
558
+ color:#1f2a44;
559
+ }
560
+ .pop .close{
561
+ position:absolute;
562
+ top: 8px;
563
+ right: 10px;
564
+ border:none;
565
+ background:transparent;
566
+ font-size: 18px;
567
+ font-weight: 900;
568
+ cursor:pointer;
569
+ color:#7b879c;
570
+ }
571
+ .checkrow{
572
+ display:flex;
573
+ align-items:center;
574
+ gap: 8px;
575
+ padding: 6px 6px;
576
+ border-radius: 10px;
577
+ }
578
+ .checkrow:hover{ background:#f6f8fe; }
579
+ .choice{
580
+ border: 1px solid #d5deef;
581
+ background: #f4f7fd;
582
+ border-radius: 999px;
583
+ padding: 6px 10px;
584
+ font-size: 12px;
585
+ font-weight: 850;
586
+ cursor:pointer;
587
+ color:#1f2a44;
588
+ display:inline-block;
589
+ margin: 4px 6px 0 0;
590
+ }
591
+ .row{
592
+ display:flex;
593
+ gap: 8px;
594
+ align-items:center;
595
+ margin-top: 8px;
596
+ }
597
+
598
+ /* Delete modal */
599
  .del-backdrop{
600
  position: fixed;
601
  inset: 0;
 
642
  .btn[disabled]{ opacity: .55; cursor: not-allowed; }
643
  """
644
  ),
645
+ ui.tags.div(
646
+ {"class": "topbar"},
647
+ ui.tags.div(
648
+ {"class": "top-left"},
649
+ ui.tags.button("Filters", {"id": "filter-btn", "type": "button"}),
650
+ ui.tags.div({"id": "active-filters", "class": "active-filters"}),
651
+ ui.tags.button("Clear", {"id": "clear-filters", "type": "button"}),
652
+ ),
653
+ ui.tags.button("+", {"id": "add-btn", "type": "button"}),
654
+ ),
655
  ui.tags.div({"id": "err", "class": "err"}),
656
  ui.tags.div({"id": "grid", "class": "cards-grid"}),
657
  ui.tags.script(
 
660
  const grid = document.getElementById("grid");
661
  const addBtn = document.getElementById("add-btn");
662
  const errBox = document.getElementById("err");
663
+ const filterBtn = document.getElementById("filter-btn");
664
+ const clearBtn = document.getElementById("clear-filters");
665
+ const activeFiltersEl = document.getElementById("active-filters");
666
+
667
+ const COMMON_TAGS = [
668
+ "Regression","Classification","Clustering","Dimensionality Reduction",
669
+ "Time Series","Survival","Bayesian","Causal",
670
+ "Linear","Tree-based","Neural Net","Ensemble",
671
+ "Interpretability","Calibration","Uncertainty",
672
+ "NLP","Computer Vision","Recommender","Anomaly Detection"
673
+ ];
674
 
675
  const state = { version: null, cards: [] };
676
  const saveTimers = new Map();
677
+ const filterTags = new Set();
678
+ let openPop = null;
679
 
680
+ // password cached in-memory (not localStorage)
681
+ let sessionPassword = null;
 
 
 
 
 
 
 
682
 
683
  function showErr(msg){
684
  if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; }
 
686
  errBox.textContent = msg;
687
  }
688
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  function escapeHtml(str){
690
  return String(str ?? "")
691
  .replaceAll("&", "&amp;")
 
702
  .replaceAll(">", "&gt;");
703
  }
704
 
705
+ async function api(path, opts={}){
706
+ const res = await fetch(path, {
707
+ headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
708
+ cache: "no-store",
709
+ ...opts
710
+ });
711
+ const data = await res.json().catch(() => ({}));
712
+ if(!res.ok){
713
+ throw new Error(data?.error || ("HTTP " + res.status));
714
  }
715
+ return data;
716
+ }
717
+
718
+ function ensurePassword(actionLabel){
719
+ if(sessionPassword) return sessionPassword;
720
+ const p = (prompt(`Password required to ${actionLabel}:`) || "").trim();
721
+ if(!p) return null;
722
+ sessionPassword = p;
723
+ return sessionPassword;
724
+ }
725
+
726
+ function tagUniverse(){
727
+ const set = new Set(COMMON_TAGS);
728
+ (state.cards || []).forEach(c => (c.tags || []).forEach(t => set.add(String(t))));
729
+ return Array.from(set).sort((a,b) => a.localeCompare(b));
730
  }
731
 
732
+ function closePop(){
733
+ if(openPop){
734
+ openPop.remove();
735
+ openPop = null;
 
736
  }
 
737
  }
738
 
739
+ function openPopoverNear(anchorEl, html){
740
+ closePop();
741
+ const pop = document.createElement("div");
742
+ pop.className = "pop";
743
+ pop.innerHTML = html;
744
+ document.body.appendChild(pop);
745
+ openPop = pop;
746
+
747
+ const r = anchorEl.getBoundingClientRect();
748
+ const pad = 10;
749
+ let left = Math.min(r.left, window.innerWidth - pop.offsetWidth - pad);
750
+ left = Math.max(pad, left);
751
+ let top = r.bottom + 10;
752
+ if(top + pop.offsetHeight + pad > window.innerHeight){
753
+ top = Math.max(pad, r.top - pop.offsetHeight - 10);
754
  }
755
+ pop.style.left = `${left}px`;
756
+ pop.style.top = `${top}px`;
757
+
758
+ const closeBtn = pop.querySelector(".close");
759
+ if(closeBtn) closeBtn.addEventListener("click", closePop);
760
+
761
+ setTimeout(() => {
762
+ const onDoc = (e) => {
763
+ if(!openPop) return;
764
+ if(openPop.contains(e.target)) return;
765
+ if(anchorEl.contains(e.target)) return;
766
+ closePop();
767
+ document.removeEventListener("mousedown", onDoc);
768
+ };
769
+ document.addEventListener("mousedown", onDoc);
770
+ }, 0);
771
+
772
+ return pop;
773
  }
774
 
775
+ function renderActiveFilters(){
776
+ activeFiltersEl.innerHTML = "";
777
+ Array.from(filterTags).sort().forEach(t => {
778
+ const pill = document.createElement("div");
779
+ pill.className = "pill";
780
+ pill.innerHTML = `${escapeHtml(t)} <span class="x">×</span>`;
781
+ pill.querySelector(".x").addEventListener("click", () => {
782
+ filterTags.delete(t);
783
+ renderActiveFilters();
784
+ render();
785
+ });
786
+ activeFiltersEl.appendChild(pill);
787
+ });
788
+ }
789
 
790
+ function passesFilter(card){
791
+ if(filterTags.size === 0) return true;
792
+ const tags = new Set((card.tags || []).map(String));
793
+ for(const f of filterTags){
794
+ if(tags.has(f)) return true; // OR logic
795
+ }
796
+ return false;
797
+ }
798
 
799
+ function openFilterPopover(){
800
+ const all = tagUniverse();
801
+ const html = `
802
+ <button class="close" type="button">×</button>
803
+ <h4>Filter by tags</h4>
804
+ <div style="max-height: 320px; overflow:auto; padding-right: 4px;">
805
+ ${all.map(t => `
806
+ <div class="checkrow">
807
+ <input type="checkbox" class="flt" data-tag="${escapeAttr(t)}" ${filterTags.has(t) ? "checked" : ""}/>
808
+ <div style="font-weight:850; font-size:12px; color:#1f2a44;">${escapeHtml(t)}</div>
809
+ </div>
810
+ `).join("")}
811
+ </div>
812
+ <div class="row" style="justify-content:flex-end;">
813
+ <button class="choice" id="fltClear" type="button">Clear</button>
814
+ </div>
815
  `;
816
+ const pop = openPopoverNear(filterBtn, html);
817
+
818
+ pop.querySelectorAll(".flt").forEach(cb => {
819
+ cb.addEventListener("change", () => {
820
+ const t = cb.getAttribute("data-tag");
821
+ if(!t) return;
822
+ if(cb.checked) filterTags.add(t);
823
+ else filterTags.delete(t);
824
+ renderActiveFilters();
825
+ render();
826
  });
827
  });
828
 
829
+ pop.querySelector("#fltClear").addEventListener("click", () => {
830
+ filterTags.clear();
831
+ renderActiveFilters();
832
+ render();
833
+ closePop();
834
  });
835
  }
836
 
837
+ // ------------------------------------------------------------
838
+ // Delete modal (3 checks; final check is password)
839
+ // ------------------------------------------------------------
840
+ async function doDelete(cardId, password){
841
+ const data = await api(`/api/cards/${encodeURIComponent(cardId)}`, {
842
+ method: "DELETE",
843
+ headers: { "X-Password": password }
844
+ });
845
  state.version = data.version;
846
  state.cards = data.cards;
847
  showErr("");
848
+ renderActiveFilters();
849
  render();
850
  }
851
 
852
  function openDeleteModal(cardId){
853
+ closePop();
854
+
855
  const backdrop = document.createElement("div");
856
  backdrop.className = "del-backdrop";
857
  backdrop.innerHTML = `
 
864
  <label><input type="checkbox" id="chk2" /> I confirm I want to delete this card</label>
865
  </div>
866
 
867
+ <label style="margin:8px 0 4px; color:#1f2a44; font-weight:900;">Password</label>
868
+ <input type="password" id="pw" value="" />
869
 
870
  <div class="del-actions">
871
  <button class="btn" id="cancel" type="button">Cancel</button>
 
877
 
878
  const chk1 = backdrop.querySelector("#chk1");
879
  const chk2 = backdrop.querySelector("#chk2");
880
+ const pw = backdrop.querySelector("#pw");
881
  const cancel = backdrop.querySelector("#cancel");
882
  const confirmBtn = backdrop.querySelector("#confirm");
883
 
884
  function updateEnabled(){
885
+ confirmBtn.disabled = !(chk1.checked && chk2.checked && (pw.value || "").trim().length > 0);
886
  }
887
  chk1.addEventListener("change", updateEnabled);
888
  chk2.addEventListener("change", updateEnabled);
889
+ pw.addEventListener("input", updateEnabled);
890
 
891
  function close(){ backdrop.remove(); }
892
  cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
 
894
 
895
  confirmBtn.addEventListener("click", async (e) => {
896
  e.preventDefault();
897
+ const p = (pw.value || "").trim();
898
+ if(!p) return;
899
+ try {
900
+ // cache for this session so Add can reuse
901
+ sessionPassword = p;
902
+ await doDelete(cardId, p);
903
+ } catch(err) {
904
+ showErr(String(err.message || err));
905
+ }
906
  close();
907
  });
908
 
909
+ pw.focus();
910
  }
911
 
912
+ // ------------------------------------------------------------
913
+ // Cards rendering
914
+ // ------------------------------------------------------------
915
+ function render(){
916
+ const visible = (state.cards || []).filter(passesFilter);
 
 
 
 
 
 
 
 
917
 
918
+ if((state.cards || []).length === 0){
 
 
919
  grid.innerHTML = `<div class="empty"><b>No cards yet.</b><div>Click the + to add your first card.</div></div>`;
920
  return;
921
  }
 
 
 
922
  if(visible.length === 0){
923
+ grid.innerHTML = `<div class="empty"><b>No matches.</b><div>Adjust filters to see cards.</div></div>`;
924
  return;
925
  }
926
 
 
931
  card.className = "card";
932
  card.dataset.id = c.id;
933
 
934
+ const modelText = (c.model || "").trim();
935
+ if(modelText.length > 0) card.classList.add("has-title");
 
 
936
 
937
+ let tags = Array.isArray(c.tags) ? c.tags.map(String) : [];
 
 
 
 
 
 
 
938
 
939
  card.innerHTML = `
940
  <div class="card-header">
941
  <div class="header-left">
942
  <input class="title-input" type="text" value="${escapeHtml(c.model)}" placeholder="Model" />
943
+ <div class="tag-bar" style="display:${modelText ? "flex" : "none"};">
944
+ <span class="tags"></span>
945
+ <button class="tag-add" type="button">+ Tag</button>
946
+ </div>
947
  </div>
948
  <button class="del-btn" type="button" title="Delete">×</button>
949
  </div>
 
953
  <label>How it Works</label>
954
  <textarea class="how">${escapeHtml(c.how_it_works)}</textarea>
955
  </div>
 
956
  <div class="panel">
957
  <label>Use Cases</label>
958
  <textarea class="use">${escapeHtml(c.use_cases)}</textarea>
 
966
  </div>
967
  `;
968
 
969
+ const delBtn = card.querySelector(".del-btn");
970
+ const titleEl = card.querySelector(".title-input");
971
+ const howEl = card.querySelector(".how");
972
+ const useEl = card.querySelector(".use");
973
+ const linkEl = card.querySelector(".link");
974
  const linkWrap = card.querySelector(".linkwrap");
975
+ const tagBar = card.querySelector(".tag-bar");
976
+ const tagsWrap = card.querySelector(".tags");
977
  const tagAddBtn = card.querySelector(".tag-add");
978
 
979
  function updateLink(){
 
983
  : "";
984
  }
985
 
986
+ function updateTitleStyle(){
987
  const has = (titleEl.value || "").trim().length > 0;
988
  card.classList.toggle("has-title", has);
989
+ if(tagBar) tagBar.style.display = has ? "flex" : "none";
 
 
 
990
  }
991
 
992
+ function renderTags(){
993
+ tagsWrap.innerHTML = "";
994
+ tags.forEach(t => {
995
+ const chip = document.createElement("span");
996
+ chip.className = "tag-chip";
997
+ chip.innerHTML = `${escapeHtml(t)} <span class="rm">×</span>`;
998
+ chip.addEventListener("click", (e) => {
999
+ e.preventDefault();
1000
+ e.stopPropagation();
1001
+ tags = tags.filter(x => x !== t);
1002
+ renderTags();
1003
+ scheduleSave({ tags });
1004
+ renderActiveFilters();
1005
+ });
1006
+ tagsWrap.appendChild(chip);
1007
+ });
1008
+ }
1009
+
1010
+ async function saveNow(extra = null){
1011
  const payload = {
1012
  model: titleEl.value || "",
1013
  how_it_works: howEl.value || "",
1014
  use_cases: useEl.value || "",
1015
  link: linkEl.value || "",
1016
  };
1017
+ if(extra) Object.assign(payload, extra);
1018
 
1019
  try{
1020
  const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
 
1025
  state.cards = data.cards;
1026
  showErr("");
1027
  updateLink();
1028
+ updateTitleStyle();
 
 
1029
  }catch(e){
1030
  showErr(String(e.message || e));
1031
  }
1032
  }
1033
 
1034
+ function scheduleSave(extra = null){
1035
  updateLink();
1036
+ updateTitleStyle();
1037
  const existing = saveTimers.get(c.id);
1038
  if(existing) clearTimeout(existing);
1039
+ saveTimers.set(c.id, setTimeout(() => saveNow(extra), 350));
1040
  }
1041
 
1042
+ // Tag adding popover with suggestions + custom
1043
+ function openTagPopover(){
1044
+ if(!(titleEl.value || "").trim()) return;
1045
 
1046
+ const all = tagUniverse().filter(t => !tags.includes(t));
 
 
 
 
1047
 
1048
+ const html = `
1049
+ <button class="close" type="button">×</button>
1050
+ <h4>Add tags</h4>
1051
+ <div>
1052
+ ${all.slice(0, 18).map(t => `<button class="choice" data-tag="${escapeAttr(t)}" type="button">${escapeHtml(t)}</button>`).join("")}
1053
+ </div>
1054
+ <div class="row">
1055
+ <input id="customTag" type="text" placeholder="Custom tag…" />
1056
+ <button class="choice" id="addCustom" type="button">Add</button>
1057
+ </div>
1058
+ `;
1059
+
1060
+ const pop = openPopoverNear(tagAddBtn, html);
1061
+
1062
+ pop.querySelectorAll(".choice[data-tag]").forEach(btn => {
1063
+ btn.addEventListener("click", () => {
1064
+ const t = btn.getAttribute("data-tag");
1065
+ if(!t) return;
1066
+ if(!tags.includes(t)) tags = [...tags, t];
1067
+ renderTags();
1068
+ scheduleSave({ tags });
1069
+ renderActiveFilters();
1070
+ });
1071
  });
 
1072
 
1073
+ const customInput = pop.querySelector("#customTag");
1074
+ const addCustom = pop.querySelector("#addCustom");
1075
+ function addCustomTag(){
1076
+ const t = (customInput.value || "").trim();
1077
+ if(!t) return;
1078
+ if(!tags.includes(t)) tags = [...tags, t];
1079
+ customInput.value = "";
1080
+ renderTags();
1081
+ scheduleSave({ tags });
1082
+ renderActiveFilters();
1083
+ }
1084
+ addCustom.addEventListener("click", addCustomTag);
1085
+ customInput.addEventListener("keydown", (e) => {
1086
+ if(e.key === "Enter"){ e.preventDefault(); addCustomTag(); }
1087
  });
1088
+ customInput.focus();
1089
  }
1090
 
1091
+ // events
1092
+ [titleEl, howEl, useEl, linkEl].forEach(el => el.addEventListener("input", () => scheduleSave()));
1093
+ tagAddBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openTagPopover(); });
1094
+
1095
+ delBtn.addEventListener("click", (e) => {
1096
+ e.preventDefault();
1097
+ e.stopPropagation();
1098
+ openDeleteModal(c.id);
1099
+ });
1100
+
1101
  updateLink();
1102
+ updateTitleStyle();
1103
+ renderTags();
1104
  grid.appendChild(card);
1105
  });
1106
  }
1107
 
1108
+ // ------------------------------------------------------------
1109
+ // Top bar filter controls
1110
+ // ------------------------------------------------------------
1111
+ filterBtn.addEventListener("click", (e) => {
1112
+ e.preventDefault();
1113
+ e.stopPropagation();
1114
+ openFilterPopover();
1115
+ });
1116
 
1117
+ clearBtn.addEventListener("click", (e) => {
1118
+ e.preventDefault();
1119
+ filterTags.clear();
1120
+ renderActiveFilters();
1121
+ render();
1122
+ });
1123
+
1124
+ // ------------------------------------------------------------
1125
+ // Add card (password required)
1126
+ // ------------------------------------------------------------
1127
+ addBtn.addEventListener("click", async () => {
1128
+ const p = ensurePassword("add a card");
1129
+ if(!p) return;
1130
+
1131
+ try{
1132
+ const data = await api("/api/cards", {
1133
+ method: "POST",
1134
+ headers: { "X-Password": p },
1135
+ });
1136
+ state.version = data.version;
1137
+ state.cards = data.cards;
1138
+ showErr("");
1139
+ renderActiveFilters();
1140
+ render();
1141
+ }catch(e){
1142
+ // if password wrong, forget cached
1143
+ sessionPassword = null;
1144
+ showErr(String(e.message || e));
1145
+ }
1146
+ });
1147
+
1148
+ // ------------------------------------------------------------
1149
+ // Polling refresh
1150
+ // ------------------------------------------------------------
1151
  async function refresh(){
1152
  try{
1153
  const active = document.activeElement;
 
1159
  state.version = data.version;
1160
  state.cards = data.cards;
1161
  showErr("");
1162
+ renderActiveFilters();
1163
  render();
1164
  }
1165
  }catch(e){
 
1167
  }
1168
  }
1169
 
1170
+ (async () => {
1171
+ await refresh();
1172
+ renderActiveFilters();
1173
+ render();
1174
+ setInterval(refresh, 2500);
1175
+ })();
 
 
 
 
 
 
 
 
1176
  })();
1177
  """
1178
  ),