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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +419 -257
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py (Shiny for Python UI + Starlette JSON API, shared persistence)
2
 
3
  from __future__ import annotations
4
 
@@ -17,7 +17,7 @@ from starlette.routing import Mount, Route
17
 
18
 
19
  # -----------------------------------------------------------------------------
20
- # Shared persistence (server-side JSON file)
21
  # -----------------------------------------------------------------------------
22
 
23
  def _pick_state_dir() -> Path:
@@ -37,24 +37,7 @@ STATE_DIR = _pick_state_dir()
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": "",
57
- }
58
 
59
 
60
  def _normalize_tags(x) -> list[str]:
@@ -64,7 +47,7 @@ def _normalize_tags(x) -> list[str]:
64
  x = [t.strip() for t in x.split(",")]
65
  if not isinstance(x, list):
66
  return []
67
- out = []
68
  for t in x:
69
  if t is None:
70
  continue
@@ -74,14 +57,23 @@ def _normalize_tags(x) -> list[str]:
74
  return out
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
77
  def _normalize_state(obj: dict) -> dict:
78
  if not isinstance(obj, dict):
79
  obj = {}
80
-
81
  version = obj.get("version", 0)
82
  if not isinstance(version, int):
83
  version = 0
84
-
85
  cards = obj.get("cards", [])
86
  if not isinstance(cards, list):
87
  cards = []
@@ -90,30 +82,16 @@ def _normalize_state(obj: dict) -> dict:
90
  for c in cards:
91
  if not isinstance(c, dict):
92
  continue
93
-
94
- model = c.get("model", c.get("title", ""))
95
- how_it_works = c.get("how_it_works", c.get("how", ""))
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
102
- else c.get("categories", c.get("tag", c.get("model_tags", [])))
103
- )
104
- tags = _normalize_tags(tags)
105
-
106
  normalized.append(
107
  {
108
  "id": str(c.get("id") or str(uuid4())[:12]),
109
- "model": "" if model is None else str(model),
110
- "tags": tags,
111
- "how_it_works": "" if how_it_works is None else str(how_it_works),
112
- "use_cases": "" if use_cases is None else str(use_cases),
113
- "link": "" if link is None else str(link),
114
  }
115
  )
116
-
117
  return {"version": version, "cards": normalized}
118
 
119
 
@@ -121,8 +99,7 @@ def _read_state_sync() -> dict:
121
  if not STATE_PATH.exists():
122
  return {"version": 0, "cards": []}
123
  try:
124
- obj = json.loads(STATE_PATH.read_text(encoding="utf-8"))
125
- return _normalize_state(obj)
126
  except Exception:
127
  return {"version": 0, "cards": []}
128
 
@@ -147,33 +124,22 @@ def _json(data: dict, status_code: int = 200) -> JSONResponse:
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
  # -----------------------------------------------------------------------------
178
 
179
  async def api_cards(request: Request) -> Response:
@@ -181,12 +147,23 @@ async def api_cards(request: Request) -> Response:
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
191
  await set_state(state)
192
  return _json(state)
@@ -204,44 +181,20 @@ async def api_card_id(request: Request) -> Response:
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:
222
- return _json({"error": "Invalid JSON"}, status_code=400)
223
-
224
- if not isinstance(payload, dict):
225
- return _json({"error": "Invalid payload"}, status_code=400)
226
-
227
- allowed = {"model", "tags", "how_it_works", "use_cases", "link"}
228
- for k in allowed:
229
- if k not in payload:
230
- continue
231
- if k == "tags":
232
- cards[idx]["tags"] = _normalize_tags(payload.get("tags"))
233
- else:
234
- cards[idx][k] = "" if payload[k] is None else str(payload[k])
235
-
236
- state["version"] += 1
237
- await set_state(state)
238
- return _json(state)
239
-
240
  return _json({"error": "Method not allowed"}, status_code=405)
241
 
242
 
243
  # -----------------------------------------------------------------------------
244
- # Shiny UI (client uses fetch() to call the API above)
245
  # -----------------------------------------------------------------------------
246
 
247
  app_ui = ui.page_fluid(
@@ -254,10 +207,11 @@ 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;
@@ -276,6 +230,7 @@ app_ui = ui.page_fluid(
276
  min-width: 0;
277
  flex: 1 1 auto;
278
  }
 
279
  #filter-btn, #clear-filters{
280
  border-radius: 12px;
281
  padding: 9px 12px;
@@ -320,11 +275,7 @@ app_ui = ui.page_fluid(
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;
@@ -339,7 +290,6 @@ app_ui = ui.page_fluid(
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;
@@ -380,7 +330,6 @@ app_ui = ui.page_fluid(
380
  min-height: 0;
381
  }
382
 
383
- /* Header with title + tags + delete */
384
  .card-header{
385
  background: var(--headerBg);
386
  display:flex;
@@ -398,7 +347,8 @@ app_ui = ui.page_fluid(
398
  flex: 1 1 auto;
399
  min-width: 0;
400
  }
401
- .title-input{
 
402
  flex: 1 1 auto;
403
  min-width: 140px;
404
  border: none;
@@ -416,6 +366,7 @@ app_ui = ui.page_fluid(
416
  background: rgba(255,255,255,.85);
417
  border: 1px solid #d8e1ee;
418
  }
 
419
 
420
  .tag-bar{
421
  display:flex;
@@ -427,7 +378,6 @@ app_ui = ui.page_fluid(
427
  padding-bottom: 2px;
428
  }
429
  .tag-bar::-webkit-scrollbar{ display:none; }
430
-
431
  .tag-chip{
432
  border: 1px solid #d8e1ee;
433
  background: #fff;
@@ -442,10 +392,7 @@ app_ui = ui.page_fluid(
442
  cursor:pointer;
443
  white-space: nowrap;
444
  }
445
- .tag-chip .rm{
446
- font-weight: 950;
447
- opacity: .6;
448
- }
449
  .tag-add{
450
  border: 1px dashed #cfd9ea;
451
  background: transparent;
@@ -457,6 +404,39 @@ app_ui = ui.page_fluid(
457
  white-space:nowrap;
458
  }
459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  .del-btn{
461
  border: none;
462
  background: transparent;
@@ -508,6 +488,7 @@ app_ui = ui.page_fluid(
508
  overflow: auto;
509
  line-height: 1.25;
510
  }
 
511
 
512
  .card-footer{
513
  padding: 12px;
@@ -524,6 +505,7 @@ app_ui = ui.page_fluid(
524
  outline: none;
525
  background: white;
526
  }
 
527
  .resource-link{
528
  display:inline-block;
529
  margin-top: 8px;
@@ -539,7 +521,7 @@ app_ui = ui.page_fluid(
539
  color: #41516b;
540
  }
541
 
542
- /* Popover (filters + tag add) */
543
  .pop{
544
  position: fixed;
545
  background: white;
@@ -595,7 +577,42 @@ app_ui = ui.page_fluid(
595
  margin-top: 8px;
596
  }
597
 
598
- /* Delete modal */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  .del-backdrop{
600
  position: fixed;
601
  inset: 0;
@@ -626,14 +643,6 @@ app_ui = ui.page_fluid(
626
  gap: 8px;
627
  }
628
  .del-actions{ display:flex; gap: 10px; justify-content:flex-end; margin-top: 12px; }
629
- .btn{
630
- border-radius: 12px;
631
- padding: 9px 12px;
632
- font-weight: 850;
633
- cursor:pointer;
634
- border: 1px solid #d8e1ee;
635
- background: #ffffff;
636
- }
637
  .btn-danger{
638
  border: 1px solid #fecdd3;
639
  background: #fff1f2;
@@ -657,6 +666,8 @@ app_ui = ui.page_fluid(
657
  ui.tags.script(
658
  r"""
659
  (() => {
 
 
660
  const grid = document.getElementById("grid");
661
  const addBtn = document.getElementById("add-btn");
662
  const errBox = document.getElementById("err");
@@ -672,14 +683,12 @@ app_ui = ui.page_fluid(
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; }
685
  errBox.style.display="block";
@@ -715,25 +724,41 @@ app_ui = ui.page_fluid(
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){
@@ -791,7 +816,7 @@ app_ui = ui.page_fluid(
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
  }
@@ -835,28 +860,67 @@ app_ui = ui.page_fluid(
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 = `
858
  <div class="del-dialog" role="dialog" aria-modal="true">
859
- <h3>Delete card?</h3>
860
  <div class="del-muted">This is permanent and affects everyone viewing this Space.</div>
861
 
862
  <div class="del-checks">
@@ -896,85 +960,96 @@ app_ui = ui.page_fluid(
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
 
927
  grid.innerHTML = "";
928
 
929
- visible.forEach((c) => {
 
930
  const card = document.createElement("div");
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>
950
 
951
  <div class="card-main">
952
  <div class="panel">
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>
959
  </div>
960
  </div>
961
 
962
  <div class="card-footer">
963
  <label>Link</label>
964
- <input type="text" class="link" value="${escapeHtml(c.link)}" />
965
  <div class="linkwrap"></div>
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(){
980
  const url = (linkEl.value || "").trim();
@@ -983,10 +1058,16 @@ app_ui = ui.page_fluid(
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(){
@@ -999,64 +1080,52 @@ app_ui = ui.page_fluid(
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)}`, {
1021
- method: "PUT",
1022
- body: JSON.stringify(payload),
1023
- });
1024
- state.version = data.version;
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 => {
@@ -1064,9 +1133,10 @@ app_ui = ui.page_fluid(
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
 
@@ -1077,9 +1147,10 @@ app_ui = ui.page_fluid(
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) => {
@@ -1088,26 +1159,136 @@ app_ui = ui.page_fluid(
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();
@@ -1121,34 +1302,15 @@ app_ui = ui.page_fluid(
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;
1154
  const isTyping = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA");
@@ -1157,7 +1319,7 @@ app_ui = ui.page_fluid(
1157
  const data = await api("/api/cards", { method: "GET" });
1158
  if(data.version !== state.version){
1159
  state.version = data.version;
1160
- state.cards = data.cards;
1161
  showErr("");
1162
  renderActiveFilters();
1163
  render();
@@ -1168,10 +1330,10 @@ app_ui = ui.page_fluid(
1168
  }
1169
 
1170
  (async () => {
1171
- await refresh();
1172
  renderActiveFilters();
1173
  render();
1174
- setInterval(refresh, 2500);
1175
  })();
1176
  })();
1177
  """
@@ -1187,7 +1349,7 @@ app_shiny = App(app_ui, shiny_server)
1187
 
1188
  routes = [
1189
  Route("/api/cards", endpoint=api_cards, methods=["GET", "POST"]),
1190
- Route("/api/cards/{card_id}", endpoint=api_card_id, methods=["PUT", "DELETE"]),
1191
  Mount("/", app=app_shiny),
1192
  ]
1193
 
 
1
+ # app.py (Shiny for Python UI + Starlette JSON API, shared "published" board + local drafts)
2
 
3
  from __future__ import annotations
4
 
 
17
 
18
 
19
  # -----------------------------------------------------------------------------
20
+ # Shared persistence for PUBLISHED cards (server-side JSON)
21
  # -----------------------------------------------------------------------------
22
 
23
  def _pick_state_dir() -> Path:
 
37
  STATE_PATH = STATE_DIR / "cards_state.json"
38
  STATE_LOCK = asyncio.Lock()
39
 
40
+ SERVER_PASSWORD = (os.environ.get("password") or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
  def _normalize_tags(x) -> list[str]:
 
47
  x = [t.strip() for t in x.split(",")]
48
  if not isinstance(x, list):
49
  return []
50
+ out: list[str] = []
51
  for t in x:
52
  if t is None:
53
  continue
 
57
  return out
58
 
59
 
60
+ def _new_published_card(payload: dict) -> dict:
61
+ return {
62
+ "id": str(uuid4())[:12],
63
+ "model": str(payload.get("model") or "").strip(),
64
+ "tags": _normalize_tags(payload.get("tags")),
65
+ "how_it_works": str(payload.get("how_it_works") or ""),
66
+ "use_cases": str(payload.get("use_cases") or ""),
67
+ "link": str(payload.get("link") or "").strip(),
68
+ }
69
+
70
+
71
  def _normalize_state(obj: dict) -> dict:
72
  if not isinstance(obj, dict):
73
  obj = {}
 
74
  version = obj.get("version", 0)
75
  if not isinstance(version, int):
76
  version = 0
 
77
  cards = obj.get("cards", [])
78
  if not isinstance(cards, list):
79
  cards = []
 
82
  for c in cards:
83
  if not isinstance(c, dict):
84
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  normalized.append(
86
  {
87
  "id": str(c.get("id") or str(uuid4())[:12]),
88
+ "model": str(c.get("model") or c.get("title") or ""),
89
+ "tags": _normalize_tags(c.get("tags") or c.get("categories") or []),
90
+ "how_it_works": str(c.get("how_it_works") or c.get("how") or ""),
91
+ "use_cases": str(c.get("use_cases") or c.get("use") or ""),
92
+ "link": str(c.get("link") or c.get("resource_url") or ""),
93
  }
94
  )
 
95
  return {"version": version, "cards": normalized}
96
 
97
 
 
99
  if not STATE_PATH.exists():
100
  return {"version": 0, "cards": []}
101
  try:
102
+ return _normalize_state(json.loads(STATE_PATH.read_text(encoding="utf-8")))
 
103
  except Exception:
104
  return {"version": 0, "cards": []}
105
 
 
124
  return JSONResponse(data, status_code=status_code, headers={"Cache-Control": "no-store"})
125
 
126
 
127
+ def _check_password(request: Request, payload: dict | None = None) -> bool:
 
 
 
 
 
 
 
 
 
 
128
  if not SERVER_PASSWORD:
 
129
  return False
130
+ provided = request.headers.get("X-Password")
 
131
  if not provided and isinstance(payload, dict):
132
  provided = payload.get("password")
 
133
  if not isinstance(provided, str):
134
  return False
 
135
  return hmac.compare_digest(provided.strip(), SERVER_PASSWORD)
136
 
137
 
138
  # -----------------------------------------------------------------------------
139
+ # API: published cards only
140
+ # GET /api/cards -> list published
141
+ # POST /api/cards -> publish a new card (requires password)
142
+ # DELETE /api/cards/{id} -> delete published (requires password)
143
  # -----------------------------------------------------------------------------
144
 
145
  async def api_cards(request: Request) -> Response:
 
147
  return _json(await get_state())
148
 
149
  if request.method == "POST":
150
+ try:
151
+ payload = await request.json()
152
+ except Exception:
153
+ payload = {}
154
+
155
+ if not isinstance(payload, dict) or not _check_password(request, payload):
156
+ return _json({"error": "Unauthorized"}, status_code=401)
157
+
158
+ # Create published card from payload
159
+ card = _new_published_card(payload)
160
+
161
+ # Basic "filled out" guard server-side (model required)
162
+ if not card["model"]:
163
+ return _json({"error": "Model is required"}, status_code=400)
164
 
165
  state = await get_state()
166
+ state["cards"].append(card)
167
  state["version"] += 1
168
  await set_state(state)
169
  return _json(state)
 
181
  return _json({"error": "Card not found"}, status_code=404)
182
 
183
  if request.method == "DELETE":
184
+ # admin delete
185
+ if not _check_password(request):
186
+ return _json({"error": "Unauthorized"}, status_code=401)
187
 
188
  cards.pop(idx)
189
  state["version"] += 1
190
  await set_state(state)
191
  return _json(state)
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  return _json({"error": "Method not allowed"}, status_code=405)
194
 
195
 
196
  # -----------------------------------------------------------------------------
197
+ # Shiny UI (client-side drafts + server-side published)
198
  # -----------------------------------------------------------------------------
199
 
200
  app_ui = ui.page_fluid(
 
207
  --cardBg:#ffffff;
208
  --muted:#6b778c;
209
  --headerBg:#f3f6fb;
210
+ --good:#167d3a;
211
  }
212
  body { background: #f3f6fb; }
213
 
214
+ /* Top bar: Filters UI moved into it */
215
  .topbar{
216
  background: linear-gradient(0deg, var(--navy2), var(--navy));
217
  padding: 12px 14px;
 
230
  min-width: 0;
231
  flex: 1 1 auto;
232
  }
233
+
234
  #filter-btn, #clear-filters{
235
  border-radius: 12px;
236
  padding: 9px 12px;
 
275
  white-space: nowrap;
276
  user-select:none;
277
  }
278
+ .pill .x{ cursor:pointer; font-weight: 900; opacity: .9; }
 
 
 
 
279
 
280
  #add-btn{
281
  background: white;
 
290
  box-shadow: 0 2px 8px rgba(0,0,0,.14);
291
  flex: 0 0 auto;
292
  }
 
293
 
294
  .err{
295
  margin: 0 16px 10px 16px;
 
330
  min-height: 0;
331
  }
332
 
 
333
  .card-header{
334
  background: var(--headerBg);
335
  display:flex;
 
347
  flex: 1 1 auto;
348
  min-width: 0;
349
  }
350
+
351
+ .title-input, .title-text{
352
  flex: 1 1 auto;
353
  min-width: 140px;
354
  border: none;
 
366
  background: rgba(255,255,255,.85);
367
  border: 1px solid #d8e1ee;
368
  }
369
+ .title-text{ font-weight: 950; }
370
 
371
  .tag-bar{
372
  display:flex;
 
378
  padding-bottom: 2px;
379
  }
380
  .tag-bar::-webkit-scrollbar{ display:none; }
 
381
  .tag-chip{
382
  border: 1px solid #d8e1ee;
383
  background: #fff;
 
392
  cursor:pointer;
393
  white-space: nowrap;
394
  }
395
+ .tag-chip .rm{ font-weight: 950; opacity: .6; }
 
 
 
396
  .tag-add{
397
  border: 1px dashed #cfd9ea;
398
  background: transparent;
 
404
  white-space:nowrap;
405
  }
406
 
407
+ .hdr-right{
408
+ display:flex;
409
+ align-items:center;
410
+ gap: 8px;
411
+ flex: 0 0 auto;
412
+ }
413
+
414
+ .draft-badge{
415
+ font-size: 11px;
416
+ font-weight: 950;
417
+ padding: 6px 10px;
418
+ border-radius: 999px;
419
+ background: #fff7ed;
420
+ border: 1px solid #fed7aa;
421
+ color: #9a3412;
422
+ user-select:none;
423
+ }
424
+
425
+ .post-btn{
426
+ border-radius: 12px;
427
+ padding: 8px 12px;
428
+ font-weight: 950;
429
+ border: 1px solid rgba(0,0,0,.08);
430
+ background: #eaf7ef;
431
+ color: var(--good);
432
+ cursor:pointer;
433
+ white-space:nowrap;
434
+ }
435
+ .post-btn:disabled{
436
+ opacity: .55;
437
+ cursor: not-allowed;
438
+ }
439
+
440
  .del-btn{
441
  border: none;
442
  background: transparent;
 
488
  overflow: auto;
489
  line-height: 1.25;
490
  }
491
+ textarea[readonly]{ background:#f8fafc; }
492
 
493
  .card-footer{
494
  padding: 12px;
 
505
  outline: none;
506
  background: white;
507
  }
508
+ input[readonly]{ background:#f8fafc; }
509
  .resource-link{
510
  display:inline-block;
511
  margin-top: 8px;
 
521
  color: #41516b;
522
  }
523
 
524
+ /* Popovers */
525
  .pop{
526
  position: fixed;
527
  background: white;
 
577
  margin-top: 8px;
578
  }
579
 
580
+ /* Password modal for POST */
581
+ .pw-backdrop{
582
+ position: fixed;
583
+ inset: 0;
584
+ background: rgba(0,0,0,.35);
585
+ display:flex;
586
+ align-items:center;
587
+ justify-content:center;
588
+ z-index: 999999;
589
+ }
590
+ .pw-dialog{
591
+ width: min(520px, calc(100vw - 28px));
592
+ background: white;
593
+ border-radius: 14px;
594
+ box-shadow: 0 10px 30px rgba(0,0,0,.25);
595
+ padding: 14px 14px 12px 14px;
596
+ display:block;
597
+ }
598
+ .pw-dialog h3{ margin: 0 0 8px 0; font-size: 16px; }
599
+ .pw-muted{ color: var(--muted); font-size: 13px; margin-bottom: 10px; }
600
+ .pw-actions{ display:flex; gap: 10px; justify-content:flex-end; margin-top: 12px; }
601
+ .btn{
602
+ border-radius: 12px;
603
+ padding: 9px 12px;
604
+ font-weight: 850;
605
+ cursor:pointer;
606
+ border: 1px solid #d8e1ee;
607
+ background: #ffffff;
608
+ }
609
+ .btn-primary{
610
+ background:#eaf7ef;
611
+ border-color:#bbf7d0;
612
+ color: var(--good);
613
+ }
614
+
615
+ /* Admin delete modal (published only) */
616
  .del-backdrop{
617
  position: fixed;
618
  inset: 0;
 
643
  gap: 8px;
644
  }
645
  .del-actions{ display:flex; gap: 10px; justify-content:flex-end; margin-top: 12px; }
 
 
 
 
 
 
 
 
646
  .btn-danger{
647
  border: 1px solid #fecdd3;
648
  background: #fff1f2;
 
666
  ui.tags.script(
667
  r"""
668
  (() => {
669
+ const DRAFTS_KEY = "model_cards_drafts_v1";
670
+
671
  const grid = document.getElementById("grid");
672
  const addBtn = document.getElementById("add-btn");
673
  const errBox = document.getElementById("err");
 
683
  "NLP","Computer Vision","Recommender","Anomaly Detection"
684
  ];
685
 
686
+ const state = { version: null, published: [] };
687
+ let drafts = loadDrafts();
688
+ const saveTimers = new Map(); // draftId -> timeout for local save
689
  const filterTags = new Set();
690
  let openPop = null;
691
 
 
 
 
692
  function showErr(msg){
693
  if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; }
694
  errBox.style.display="block";
 
724
  return data;
725
  }
726
 
727
+ function uid(){
728
+ return "draft_" + (Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
729
+ }
730
+
731
+ function loadDrafts(){
732
+ try{
733
+ const raw = localStorage.getItem(DRAFTS_KEY);
734
+ const arr = JSON.parse(raw || "[]");
735
+ if(!Array.isArray(arr)) return [];
736
+ return arr.map(d => ({
737
+ id: String(d.id || uid()),
738
+ model: String(d.model || ""),
739
+ tags: Array.isArray(d.tags) ? d.tags.map(String) : [],
740
+ how_it_works: String(d.how_it_works || ""),
741
+ use_cases: String(d.use_cases || ""),
742
+ link: String(d.link || "")
743
+ }));
744
+ }catch(e){
745
+ return [];
746
+ }
747
+ }
748
+
749
+ function saveDrafts(){
750
+ localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts));
751
  }
752
 
753
  function tagUniverse(){
754
  const set = new Set(COMMON_TAGS);
755
+ for(const c of state.published){ (c.tags || []).forEach(t => set.add(String(t))); }
756
+ for(const d of drafts){ (d.tags || []).forEach(t => set.add(String(t))); }
757
+ return Array.from(set).sort((a,b)=>a.localeCompare(b));
758
  }
759
 
760
  function closePop(){
761
+ if(openPop){ openPop.remove(); openPop = null; }
 
 
 
762
  }
763
 
764
  function openPopoverNear(anchorEl, html){
 
816
  if(filterTags.size === 0) return true;
817
  const tags = new Set((card.tags || []).map(String));
818
  for(const f of filterTags){
819
+ if(tags.has(f)) return true; // OR
820
  }
821
  return false;
822
  }
 
860
  }
861
 
862
  // ------------------------------------------------------------
863
+ // POST modal (password only shown on Post)
864
  // ------------------------------------------------------------
865
+ function openPostModal(draft, onConfirm){
866
+ closePop();
867
+
868
+ const backdrop = document.createElement("div");
869
+ backdrop.className = "pw-backdrop";
870
+ backdrop.innerHTML = `
871
+ <div class="pw-dialog" role="dialog" aria-modal="true">
872
+ <h3>Post this card?</h3>
873
+ <div class="pw-muted">This will publish the card for everyone to see.</div>
874
+
875
+ <label style="margin:8px 0 4px; color:#1f2a44; font-weight:900;">Password</label>
876
+ <input type="password" id="pw" value="" />
877
+
878
+ <div class="pw-actions">
879
+ <button class="btn" id="cancel" type="button">Cancel</button>
880
+ <button class="btn btn-primary" id="confirm" type="button" disabled>Post</button>
881
+ </div>
882
+ </div>
883
+ `;
884
+ document.body.appendChild(backdrop);
885
+
886
+ const pw = backdrop.querySelector("#pw");
887
+ const cancel = backdrop.querySelector("#cancel");
888
+ const confirmBtn = backdrop.querySelector("#confirm");
889
+
890
+ function updateEnabled(){
891
+ confirmBtn.disabled = ((pw.value || "").trim().length === 0);
892
+ }
893
+ pw.addEventListener("input", updateEnabled);
894
+
895
+ function close(){ backdrop.remove(); }
896
+ cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
897
+ backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
898
+
899
+ confirmBtn.addEventListener("click", async (e) => {
900
+ e.preventDefault();
901
+ const p = (pw.value || "").trim();
902
+ if(!p) return;
903
+ try{
904
+ await onConfirm(p);
905
+ } finally {
906
+ close();
907
+ }
908
  });
909
+
910
+ pw.focus();
 
 
 
911
  }
912
 
913
+ // ------------------------------------------------------------
914
+ // Optional: admin delete published (password + 2 checks)
915
+ // ------------------------------------------------------------
916
+ function openDeletePublishedModal(cardId, onConfirm){
917
  closePop();
918
 
919
  const backdrop = document.createElement("div");
920
  backdrop.className = "del-backdrop";
921
  backdrop.innerHTML = `
922
  <div class="del-dialog" role="dialog" aria-modal="true">
923
+ <h3>Delete published card?</h3>
924
  <div class="del-muted">This is permanent and affects everyone viewing this Space.</div>
925
 
926
  <div class="del-checks">
 
960
  e.preventDefault();
961
  const p = (pw.value || "").trim();
962
  if(!p) return;
963
+ try{
964
+ await onConfirm(p);
965
+ } finally {
966
+ close();
 
 
967
  }
 
968
  });
969
 
970
  pw.focus();
971
  }
972
 
973
  // ------------------------------------------------------------
974
+ // Draft helpers
975
  // ------------------------------------------------------------
976
+ function draftFilled(d){
977
+ const hasModel = (d.model || "").trim().length > 0;
978
+ const hasSome = ((d.how_it_works || "").trim() || (d.use_cases || "").trim() || (d.link || "").trim() || (d.tags||[]).length > 0);
979
+ return hasModel && !!hasSome;
980
+ }
981
+
982
  function render(){
983
+ const visibleDrafts = drafts.filter(passesFilter);
984
+ const visiblePublished = state.published.filter(passesFilter);
985
 
986
+ if(drafts.length === 0 && state.published.length === 0){
987
+ grid.innerHTML = `<div class="empty"><b>No cards yet.</b><div>Click the + to add a draft, then Post to publish.</div></div>`;
988
  return;
989
  }
990
+
991
+ if(visibleDrafts.length === 0 && visiblePublished.length === 0){
992
  grid.innerHTML = `<div class="empty"><b>No matches.</b><div>Adjust filters to see cards.</div></div>`;
993
  return;
994
  }
995
 
996
  grid.innerHTML = "";
997
 
998
+ // ---- Drafts first (private) ----
999
+ visibleDrafts.forEach((d) => {
1000
  const card = document.createElement("div");
1001
  card.className = "card";
1002
+ card.dataset.id = d.id;
1003
 
1004
+ const modelText = (d.model || "").trim();
1005
+ if(modelText) card.classList.add("has-title");
1006
 
1007
+ let tags = Array.isArray(d.tags) ? d.tags.map(String) : [];
1008
 
1009
  card.innerHTML = `
1010
  <div class="card-header">
1011
  <div class="header-left">
1012
+ <input class="title-input" type="text" value="${escapeHtml(d.model)}" placeholder="Model" />
1013
  <div class="tag-bar" style="display:${modelText ? "flex" : "none"};">
1014
  <span class="tags"></span>
1015
  <button class="tag-add" type="button">+ Tag</button>
1016
  </div>
1017
  </div>
1018
+ <div class="hdr-right">
1019
+ <span class="draft-badge">Draft</span>
1020
+ <button class="post-btn" type="button" ${draftFilled(d) ? "" : "disabled"}>Post</button>
1021
+ <button class="del-btn" type="button" title="Delete draft">×</button>
1022
+ </div>
1023
  </div>
1024
 
1025
  <div class="card-main">
1026
  <div class="panel">
1027
  <label>How it Works</label>
1028
+ <textarea class="how">${escapeHtml(d.how_it_works)}</textarea>
1029
  </div>
1030
  <div class="panel">
1031
  <label>Use Cases</label>
1032
+ <textarea class="use">${escapeHtml(d.use_cases)}</textarea>
1033
  </div>
1034
  </div>
1035
 
1036
  <div class="card-footer">
1037
  <label>Link</label>
1038
+ <input type="text" class="link" value="${escapeHtml(d.link)}" />
1039
  <div class="linkwrap"></div>
1040
  </div>
1041
  `;
1042
 
1043
+ const titleEl = card.querySelector(".title-input");
1044
+ const howEl = card.querySelector(".how");
1045
+ const useEl = card.querySelector(".use");
1046
+ const linkEl = card.querySelector(".link");
 
1047
  const linkWrap = card.querySelector(".linkwrap");
1048
+ const tagBar = card.querySelector(".tag-bar");
1049
  const tagsWrap = card.querySelector(".tags");
1050
  const tagAddBtn = card.querySelector(".tag-add");
1051
+ const postBtn = card.querySelector(".post-btn");
1052
+ const delBtn = card.querySelector(".del-btn");
1053
 
1054
  function updateLink(){
1055
  const url = (linkEl.value || "").trim();
 
1058
  : "";
1059
  }
1060
 
1061
+ function updateTitleAndPost(){
1062
+ const hasTitle = (titleEl.value || "").trim().length > 0;
1063
+ card.classList.toggle("has-title", hasTitle);
1064
+ tagBar.style.display = hasTitle ? "flex" : "none";
1065
+
1066
+ // update draft object + post enabled
1067
+ const updated = getDraft(d.id);
1068
+ if(updated){
1069
+ postBtn.disabled = !draftFilled(updated);
1070
+ }
1071
  }
1072
 
1073
  function renderTags(){
 
1080
  e.preventDefault();
1081
  e.stopPropagation();
1082
  tags = tags.filter(x => x !== t);
1083
+ setDraftField(d.id, "tags", tags);
1084
  renderTags();
 
1085
  renderActiveFilters();
1086
+ updateTitleAndPost();
1087
  });
1088
  tagsWrap.appendChild(chip);
1089
  });
1090
  }
1091
 
1092
+ function getDraft(id){
1093
+ return drafts.find(x => x.id === id) || null;
1094
+ }
1095
+
1096
+ function setDraftField(id, key, value){
1097
+ const i = drafts.findIndex(x => x.id === id);
1098
+ if(i === -1) return;
1099
+ drafts[i] = { ...drafts[i], [key]: value };
1100
+ saveDrafts();
 
 
 
 
 
 
 
 
 
 
 
 
 
1101
  }
1102
 
1103
+ function scheduleDraftSave(){
1104
+ const existing = saveTimers.get(d.id);
 
 
1105
  if(existing) clearTimeout(existing);
1106
+ saveTimers.set(d.id, setTimeout(() => {
1107
+ setDraftField(d.id, "model", titleEl.value || "");
1108
+ setDraftField(d.id, "how_it_works", howEl.value || "");
1109
+ setDraftField(d.id, "use_cases", useEl.value || "");
1110
+ setDraftField(d.id, "link", linkEl.value || "");
1111
+ setDraftField(d.id, "tags", tags);
1112
+ updateLink();
1113
+ updateTitleAndPost();
1114
+ }, 150));
1115
  }
1116
 
 
1117
  function openTagPopover(){
1118
  if(!(titleEl.value || "").trim()) return;
 
1119
  const all = tagUniverse().filter(t => !tags.includes(t));
 
1120
  const html = `
1121
  <button class="close" type="button">×</button>
1122
  <h4>Add tags</h4>
1123
+ <div>${all.slice(0, 18).map(t => `<button class="choice" data-tag="${escapeAttr(t)}" type="button">${escapeHtml(t)}</button>`).join("")}</div>
 
 
1124
  <div class="row">
1125
  <input id="customTag" type="text" placeholder="Custom tag…" />
1126
  <button class="choice" id="addCustom" type="button">Add</button>
1127
  </div>
1128
  `;
 
1129
  const pop = openPopoverNear(tagAddBtn, html);
1130
 
1131
  pop.querySelectorAll(".choice[data-tag]").forEach(btn => {
 
1133
  const t = btn.getAttribute("data-tag");
1134
  if(!t) return;
1135
  if(!tags.includes(t)) tags = [...tags, t];
1136
+ setDraftField(d.id, "tags", tags);
1137
  renderTags();
 
1138
  renderActiveFilters();
1139
+ updateTitleAndPost();
1140
  });
1141
  });
1142
 
 
1147
  if(!t) return;
1148
  if(!tags.includes(t)) tags = [...tags, t];
1149
  customInput.value = "";
1150
+ setDraftField(d.id, "tags", tags);
1151
  renderTags();
 
1152
  renderActiveFilters();
1153
+ updateTitleAndPost();
1154
  }
1155
  addCustom.addEventListener("click", addCustomTag);
1156
  customInput.addEventListener("keydown", (e) => {
 
1159
  customInput.focus();
1160
  }
1161
 
1162
+ // Post draft to published (password prompt happens here)
1163
+ postBtn.addEventListener("click", async () => {
1164
+ const current = getDraft(d.id);
1165
+ if(!current || !draftFilled(current)) return;
1166
+
1167
+ openPostModal(current, async (password) => {
1168
+ try{
1169
+ const payload = {
1170
+ password,
1171
+ model: current.model,
1172
+ tags: current.tags || [],
1173
+ how_it_works: current.how_it_works,
1174
+ use_cases: current.use_cases,
1175
+ link: current.link,
1176
+ };
1177
+
1178
+ const data = await api("/api/cards", {
1179
+ method: "POST",
1180
+ body: JSON.stringify(payload),
1181
+ headers: { "X-Password": password },
1182
+ });
1183
+
1184
+ // remove draft locally on success
1185
+ drafts = drafts.filter(x => x.id !== current.id);
1186
+ saveDrafts();
1187
+
1188
+ // update published
1189
+ state.version = data.version;
1190
+ state.published = data.cards;
1191
+
1192
+ showErr("");
1193
+ renderActiveFilters();
1194
+ render();
1195
+ }catch(e){
1196
+ showErr(String(e.message || e));
1197
+ }
1198
+ });
1199
+ });
1200
 
1201
+ // Delete draft (local only)
1202
+ delBtn.addEventListener("click", () => {
1203
+ if(!confirm("Delete this draft? (This is only on your computer.)")) return;
1204
+ drafts = drafts.filter(x => x.id !== d.id);
1205
+ saveDrafts();
1206
+ renderActiveFilters();
1207
+ render();
1208
  });
1209
 
1210
+ [titleEl, howEl, useEl, linkEl].forEach(el => el.addEventListener("input", scheduleDraftSave));
1211
+ tagAddBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openTagPopover(); });
1212
+
1213
  updateLink();
 
1214
  renderTags();
1215
+ updateTitleAndPost();
1216
+ grid.appendChild(card);
1217
+ });
1218
+
1219
+ // ---- Published next (shared, read-only) ----
1220
+ visiblePublished.forEach((c) => {
1221
+ const card = document.createElement("div");
1222
+ card.className = "card";
1223
+ card.dataset.id = c.id;
1224
+
1225
+ const modelText = (c.model || "").trim();
1226
+ if(modelText) card.classList.add("has-title");
1227
+
1228
+ const tags = Array.isArray(c.tags) ? c.tags.map(String) : [];
1229
+
1230
+ card.innerHTML = `
1231
+ <div class="card-header">
1232
+ <div class="header-left">
1233
+ <div class="title-text">${escapeHtml(c.model || "")}</div>
1234
+ <div class="tag-bar" style="display:${modelText ? "flex" : "none"};">
1235
+ ${tags.map(t => `<span class="tag-chip" style="cursor:default;">${escapeHtml(t)}</span>`).join("")}
1236
+ </div>
1237
+ </div>
1238
+ <div class="hdr-right">
1239
+ <button class="del-btn" type="button" title="Admin delete">×</button>
1240
+ </div>
1241
+ </div>
1242
+
1243
+ <div class="card-main">
1244
+ <div class="panel">
1245
+ <label>How it Works</label>
1246
+ <textarea readonly>${escapeHtml(c.how_it_works || "")}</textarea>
1247
+ </div>
1248
+ <div class="panel">
1249
+ <label>Use Cases</label>
1250
+ <textarea readonly>${escapeHtml(c.use_cases || "")}</textarea>
1251
+ </div>
1252
+ </div>
1253
+
1254
+ <div class="card-footer">
1255
+ <label>Link</label>
1256
+ <input readonly value="${escapeHtml(c.link || "")}" />
1257
+ <div class="linkwrap"></div>
1258
+ </div>
1259
+ `;
1260
+
1261
+ const linkWrap = card.querySelector(".linkwrap");
1262
+ const delBtn = card.querySelector(".del-btn");
1263
+ const url = (c.link || "").trim();
1264
+ linkWrap.innerHTML = url
1265
+ ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>`
1266
+ : "";
1267
+
1268
+ // Admin delete (optional)
1269
+ delBtn.addEventListener("click", () => {
1270
+ openDeletePublishedModal(c.id, async (password) => {
1271
+ try{
1272
+ const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
1273
+ method: "DELETE",
1274
+ headers: { "X-Password": password },
1275
+ });
1276
+ state.version = data.version;
1277
+ state.published = data.cards;
1278
+ showErr("");
1279
+ renderActiveFilters();
1280
+ render();
1281
+ }catch(e){
1282
+ showErr(String(e.message || e));
1283
+ }
1284
+ });
1285
+ });
1286
+
1287
  grid.appendChild(card);
1288
  });
1289
  }
1290
 
1291
+ // Top bar filters
 
 
1292
  filterBtn.addEventListener("click", (e) => {
1293
  e.preventDefault();
1294
  e.stopPropagation();
 
1302
  render();
1303
  });
1304
 
1305
+ // Add draft (no password)
1306
+ addBtn.addEventListener("click", () => {
1307
+ drafts.unshift({ id: uid(), model:"", tags:[], how_it_works:"", use_cases:"", link:"" });
1308
+ saveDrafts();
1309
+ render();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1310
  });
1311
 
1312
+ // Poll published board
1313
+ async function refreshPublished(){
 
 
1314
  try{
1315
  const active = document.activeElement;
1316
  const isTyping = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA");
 
1319
  const data = await api("/api/cards", { method: "GET" });
1320
  if(data.version !== state.version){
1321
  state.version = data.version;
1322
+ state.published = data.cards || [];
1323
  showErr("");
1324
  renderActiveFilters();
1325
  render();
 
1330
  }
1331
 
1332
  (async () => {
1333
+ await refreshPublished();
1334
  renderActiveFilters();
1335
  render();
1336
+ setInterval(refreshPublished, 2500);
1337
  })();
1338
  })();
1339
  """
 
1349
 
1350
  routes = [
1351
  Route("/api/cards", endpoint=api_cards, methods=["GET", "POST"]),
1352
+ Route("/api/cards/{card_id}", endpoint=api_card_id, methods=["DELETE"]),
1353
  Mount("/", app=app_shiny),
1354
  ]
1355