benresnic commited on
Commit
1381b2a
·
verified ·
1 Parent(s): d62556c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +352 -214
app.py CHANGED
@@ -139,9 +139,10 @@ def _check_password(request: Request, payload: dict | None = None) -> bool:
139
 
140
  # -----------------------------------------------------------------------------
141
  # API: published cards only
142
- # GET /api/cards -> list published
143
- # POST /api/cards -> publish a new card (password)
144
- # PUT /api/cards/{id} -> republish/update existing (password)
 
145
  # -----------------------------------------------------------------------------
146
 
147
  async def api_cards(request: Request) -> Response:
@@ -159,8 +160,6 @@ async def api_cards(request: Request) -> Response:
159
  return _json({"error": msg}, status_code=401)
160
 
161
  card = _new_published_card(payload)
162
-
163
- # "filled out" server-side: model + both sections required
164
  if not card["model"] or not card["how_it_works"] or not card["use_cases"]:
165
  return _json({"error": "Model, How it Works, and Use Cases are required"}, status_code=400)
166
 
@@ -193,7 +192,6 @@ async def api_card_id(request: Request) -> Response:
193
  return _json({"error": msg}, status_code=401)
194
 
195
  updated = _new_published_card(payload, card_id=card_id)
196
-
197
  if not updated["model"] or not updated["how_it_works"] or not updated["use_cases"]:
198
  return _json({"error": "Model, How it Works, and Use Cases are required"}, status_code=400)
199
 
@@ -202,11 +200,21 @@ async def api_card_id(request: Request) -> Response:
202
  await set_state(state)
203
  return _json(state)
204
 
 
 
 
 
 
 
 
 
 
 
205
  return _json({"error": "Method not allowed"}, status_code=405)
206
 
207
 
208
  # -----------------------------------------------------------------------------
209
- # Shiny UI (client-side drafts + server-side published)
210
  # -----------------------------------------------------------------------------
211
 
212
  app_ui = ui.page_fluid(
@@ -223,7 +231,7 @@ app_ui = ui.page_fluid(
223
  }
224
  body { background: #f3f6fb; }
225
 
226
- /* Top bar: visible chip row + clear + + */
227
  .topbar{
228
  background: linear-gradient(0deg, var(--navy2), var(--navy));
229
  padding: 12px 14px;
@@ -242,7 +250,6 @@ app_ui = ui.page_fluid(
242
  min-width: 0;
243
  flex: 1 1 auto;
244
  }
245
-
246
  .chips-row{
247
  display:flex;
248
  gap: 8px;
@@ -309,7 +316,7 @@ app_ui = ui.page_fluid(
309
  display:none;
310
  }
311
 
312
- /* GRID: 2 columns, full width */
313
  .cards-grid{
314
  display:grid;
315
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -323,7 +330,7 @@ app_ui = ui.page_fluid(
323
  .cards-grid{ grid-template-columns: 1fr; }
324
  }
325
 
326
- /* Card = square */
327
  .card{
328
  background: var(--cardBg);
329
  border: 1px solid var(--cardBorder);
@@ -385,6 +392,7 @@ app_ui = ui.page_fluid(
385
  padding-bottom: 2px;
386
  }
387
  .tag-bar::-webkit-scrollbar{ display:none; }
 
388
  .tag-chip{
389
  border: 1px solid #d8e1ee;
390
  background: #fff;
@@ -400,7 +408,8 @@ app_ui = ui.page_fluid(
400
  white-space: nowrap;
401
  }
402
  .tag-chip .rm{ font-weight: 950; opacity: .6; }
403
- .tag-add{
 
404
  border: 1px dashed #cfd9ea;
405
  background: transparent;
406
  border-radius: 999px;
@@ -467,8 +476,6 @@ app_ui = ui.page_fluid(
467
  min-height: 0;
468
  overflow:hidden;
469
  }
470
-
471
- /* Equal-height panels */
472
  .panel{
473
  display:flex;
474
  flex-direction:column;
@@ -551,7 +558,7 @@ app_ui = ui.page_fluid(
551
  color: #41516b;
552
  }
553
 
554
- /* Popover for tag dropdown */
555
  .pop{
556
  position: fixed;
557
  background: white;
@@ -560,7 +567,7 @@ app_ui = ui.page_fluid(
560
  box-shadow: 0 10px 30px rgba(0,0,0,.18);
561
  padding: 10px;
562
  z-index: 999999;
563
- width: 320px;
564
  max-width: calc(100vw - 24px);
565
  }
566
  .pop h4{
@@ -580,19 +587,23 @@ app_ui = ui.page_fluid(
580
  cursor:pointer;
581
  color:#7b879c;
582
  }
583
- .pop select{
584
- width:100%;
585
- border: 1px solid #d8e1ee;
586
- border-radius: 12px;
587
- padding: 10px 12px;
588
- font-size: 13px;
589
- background:#fff;
590
- outline:none;
591
  }
 
 
 
 
 
 
 
 
592
  .pop .row{
593
  display:flex;
594
- justify-content:flex-end;
595
- gap: 10px;
596
  margin-top: 10px;
597
  }
598
  .pop .btn{
@@ -604,8 +615,8 @@ app_ui = ui.page_fluid(
604
  background: #ffffff;
605
  }
606
 
607
- /* Post/Republish modal */
608
- .pw-backdrop{
609
  position: fixed;
610
  inset: 0;
611
  background: rgba(0,0,0,.35);
@@ -614,17 +625,33 @@ app_ui = ui.page_fluid(
614
  justify-content:center;
615
  z-index: 999999;
616
  }
617
- .pw-dialog{
618
- width: min(560px, calc(100vw - 28px));
 
619
  background: white;
620
  border-radius: 14px;
621
  box-shadow: 0 10px 30px rgba(0,0,0,.25);
622
- padding: 14px 14px 12px 14px;
623
- display:block;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  }
625
- .pw-dialog h3{ margin: 0 0 8px 0; font-size: 16px; }
626
- .pw-muted{ color: var(--muted); font-size: 13px; margin-bottom: 10px; }
627
- .pw-actions{ display:flex; gap: 10px; justify-content:flex-end; margin-top: 12px; }
628
  .btn{
629
  border-radius: 12px;
630
  padding: 9px 12px;
@@ -639,23 +666,26 @@ app_ui = ui.page_fluid(
639
  color: var(--good);
640
  font-weight: 950;
641
  }
 
 
 
 
 
 
 
642
 
643
- /* Edit modal (republish) */
644
  .edit-grid{
645
  display:grid;
646
  grid-template-columns: 1fr;
647
  gap: 10px;
648
  }
649
- .edit-grid textarea{
650
- height: 120px;
651
- }
652
  .edit-tags{
653
  display:flex;
654
- gap: 8px;
655
  flex-wrap: wrap;
 
656
  align-items:center;
657
  }
658
- .edit-tags .tag-chip{ cursor:pointer; }
659
  """
660
  ),
661
  ui.tags.div(
@@ -670,7 +700,7 @@ app_ui = ui.page_fluid(
670
  ui.tags.script(
671
  r"""
672
  (() => {
673
- const DRAFTS_KEY = "model_cards_drafts_v3";
674
 
675
  const grid = document.getElementById("grid");
676
  const addBtn = document.getElementById("add-btn");
@@ -811,7 +841,7 @@ app_ui = ui.page_fluid(
811
  });
812
 
813
  // ------------------------------------------------------------
814
- // Small popover with dropdown to add a tag
815
  // ------------------------------------------------------------
816
  function closePop(){
817
  if(openPop){ openPop.remove(); openPop = null; }
@@ -853,82 +883,133 @@ app_ui = ui.page_fluid(
853
  return pop;
854
  }
855
 
856
- function openTagDropdown(anchorEl, currentTags, onAdd){
857
- const available = tagUniverse().filter(t => !currentTags.includes(t));
858
- const options = available.length
859
- ? available.map(t => `<option value="${escapeAttr(t)}">${escapeHtml(t)}</option>`).join("")
860
- : `<option value="">No tags available</option>`;
861
-
862
  const html = `
863
  <button class="close" type="button">×</button>
864
- <h4>Add a tag</h4>
865
- <select id="tagSel" ${available.length ? "" : "disabled"}>${options}</select>
 
 
 
 
 
 
 
 
 
 
 
866
  <div class="row">
867
- <button class="btn" id="cancel" type="button">Cancel</button>
868
- <button class="btn" id="add" type="button" ${available.length ? "" : "disabled"}>Add</button>
869
  </div>
870
  `;
871
  const pop = openPopoverNear(anchorEl, html);
872
- const sel = pop.querySelector("#tagSel");
873
- pop.querySelector("#cancel").addEventListener("click", closePop);
874
- pop.querySelector("#add").addEventListener("click", () => {
875
- const v = (sel.value || "").trim();
876
- if(!v) return;
877
- onAdd(v);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
  closePop();
 
 
 
 
 
 
 
 
879
  });
 
 
880
  }
881
 
882
  // ------------------------------------------------------------
883
- // Post/Republish password modal
884
  // ------------------------------------------------------------
885
- function openPasswordModal(title, subtitle, onConfirm){
886
  const backdrop = document.createElement("div");
887
- backdrop.className = "pw-backdrop";
888
  backdrop.innerHTML = `
889
- <div class="pw-dialog" role="dialog" aria-modal="true">
890
- <h3>${escapeHtml(title)}</h3>
891
- <div class="pw-muted">${escapeHtml(subtitle)}</div>
892
-
893
- <label style="margin:8px 0 4px; color:#1f2a44; font-weight:900;">Password</label>
894
- <input type="password" id="pw" value="" />
895
-
896
- <div class="pw-actions">
897
  <button class="btn" id="cancel" type="button">Cancel</button>
898
- <button class="btn btn-primary" id="confirm" type="button" disabled>Confirm</button>
899
  </div>
900
  </div>
901
  `;
902
  document.body.appendChild(backdrop);
903
 
904
- const pw = backdrop.querySelector("#pw");
905
  const cancel = backdrop.querySelector("#cancel");
906
- const confirmBtn = backdrop.querySelector("#confirm");
907
-
908
- function updateEnabled(){
909
- confirmBtn.disabled = ((pw.value || "").trim().length === 0);
910
- }
911
- pw.addEventListener("input", updateEnabled);
912
 
913
  function close(){ backdrop.remove(); }
 
914
  cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
915
  backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
916
 
917
- confirmBtn.addEventListener("click", async (e) => {
918
- e.preventDefault();
919
- const p = (pw.value || "").trim();
920
- if(!p) return;
921
- try{ await onConfirm(p); } finally { close(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922
  });
923
 
924
- pw.focus();
 
 
925
  }
926
 
927
  // ------------------------------------------------------------
928
  // Draft helpers
929
  // ------------------------------------------------------------
930
  function draftFilled(d){
931
- // Require model + both sections to enable Post
932
  return (d.model || "").trim().length > 0
933
  && (d.how_it_works || "").trim().length > 0
934
  && (d.use_cases || "").trim().length > 0;
@@ -956,6 +1037,7 @@ app_ui = ui.page_fluid(
956
 
957
  const modelText = (d.model || "").trim();
958
  if(modelText) card.classList.add("has-title");
 
959
  let tags = Array.isArray(d.tags) ? d.tags.map(String) : [];
960
 
961
  card.innerHTML = `
@@ -964,7 +1046,7 @@ app_ui = ui.page_fluid(
964
  <input class="title-input" type="text" value="${escapeHtml(d.model)}" placeholder="Model" />
965
  <div class="tag-bar" style="display:${modelText ? "flex" : "none"};">
966
  <span class="tags"></span>
967
- <button class="tag-add" type="button">+ Tag</button>
968
  </div>
969
  </div>
970
  <div class="hdr-right">
@@ -1005,13 +1087,15 @@ app_ui = ui.page_fluid(
1005
  const linkWrap = card.querySelector(".linkwrap");
1006
  const tagBar = card.querySelector(".tag-bar");
1007
  const tagsWrap = card.querySelector(".tags");
1008
- const tagAddBtn = card.querySelector(".tag-add");
1009
  const postBtn = card.querySelector(".post-btn");
1010
  const delBtn = card.querySelector(".del-btn");
1011
 
1012
  function updateLink(){
1013
  const url = (linkEl.value || "").trim();
1014
- linkWrap.innerHTML = url ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>` : "";
 
 
1015
  }
1016
 
1017
  function getDraft(){
@@ -1069,13 +1153,13 @@ app_ui = ui.page_fluid(
1069
  }, 150));
1070
  }
1071
 
1072
- // Tag add via dropdown (available tags)
1073
- tagAddBtn.addEventListener("click", (e) => {
1074
  e.preventDefault();
1075
  e.stopPropagation();
1076
  if(!(titleEl.value || "").trim()) return;
1077
- openTagDropdown(tagAddBtn, tags, (t) => {
1078
- if(!tags.includes(t)) tags = [...tags, t];
1079
  setDraft({ tags });
1080
  renderTags();
1081
  renderFilterChips();
@@ -1083,15 +1167,27 @@ app_ui = ui.page_fluid(
1083
  });
1084
  });
1085
 
1086
- // Post (password shown here)
1087
- postBtn.addEventListener("click", async () => {
1088
  const cur = getDraft();
1089
  if(!cur || !draftFilled(cur)) return;
1090
 
1091
- openPasswordModal(
1092
- "Post this card?",
1093
- "This will publish the card for everyone to see.",
1094
- async (password) => {
 
 
 
 
 
 
 
 
 
 
 
 
1095
  const payload = {
1096
  password,
1097
  model: cur.model,
@@ -1101,32 +1197,31 @@ app_ui = ui.page_fluid(
1101
  link: cur.link,
1102
  author: cur.author,
1103
  };
1104
- try{
1105
- const data = await api("/api/cards", {
1106
- method: "POST",
1107
- body: JSON.stringify(payload),
1108
- headers: { "X-Password": password },
1109
- });
1110
 
1111
- // remove draft
1112
- drafts = drafts.filter(x => x.id !== cur.id);
1113
- saveDrafts();
1114
 
1115
- // update published
1116
- state.version = data.version;
1117
- state.published = data.cards;
1118
 
1119
- showErr("");
1120
- renderFilterChips();
1121
- render();
1122
- }catch(e){
1123
- showErr(String(e.message || e));
1124
- }
 
 
1125
  }
1126
- );
1127
  });
1128
 
1129
- // Delete draft (local only)
1130
  delBtn.addEventListener("click", () => {
1131
  if(!confirm("Delete this draft? (Only on your computer.)")) return;
1132
  drafts = drafts.filter(x => x.id !== d.id);
@@ -1135,6 +1230,7 @@ app_ui = ui.page_fluid(
1135
  render();
1136
  });
1137
 
 
1138
  [titleEl, howEl, useEl, linkEl, byEl].forEach(el => el.addEventListener("input", scheduleDraftSave));
1139
 
1140
  updateLink();
@@ -1143,7 +1239,7 @@ app_ui = ui.page_fluid(
1143
  grid.appendChild(card);
1144
  });
1145
 
1146
- // ---------------- Published (shared, read-only) ----------------
1147
  visiblePublished.forEach((c) => {
1148
  const card = document.createElement("div");
1149
  card.className = "card";
@@ -1159,6 +1255,7 @@ app_ui = ui.page_fluid(
1159
  </div>
1160
  <div class="hdr-right">
1161
  <button class="edit-btn" type="button">Edit</button>
 
1162
  </div>
1163
  </div>
1164
 
@@ -1183,19 +1280,70 @@ app_ui = ui.page_fluid(
1183
 
1184
  const linkWrap = card.querySelector(".linkwrap");
1185
  const editBtn = card.querySelector(".edit-btn");
 
 
1186
  const url = (c.link || "").trim();
1187
- linkWrap.innerHTML = url ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>` : "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1188
 
1189
- // Edit -> modal with fields -> Republish (password required)
1190
  editBtn.addEventListener("click", () => {
1191
- // build edit modal UI
1192
- const backdrop = document.createElement("div");
1193
- backdrop.className = "pw-backdrop";
1194
- backdrop.innerHTML = `
1195
- <div class="pw-dialog" role="dialog" aria-modal="true">
1196
- <h3>Edit & Republish</h3>
1197
- <div class="pw-muted">Edits become public only after you enter the password and republish.</div>
1198
 
 
 
 
 
1199
  <div class="edit-grid">
1200
  <div>
1201
  <label>Model</label>
@@ -1206,7 +1354,7 @@ app_ui = ui.page_fluid(
1206
  <label>Tags</label>
1207
  <div class="edit-tags" id="tags"></div>
1208
  <div style="margin-top:8px;">
1209
- <button class="tag-add" id="addTag" type="button">+ Tag</button>
1210
  </div>
1211
  </div>
1212
 
@@ -1235,104 +1383,94 @@ app_ui = ui.page_fluid(
1235
  <input id="pw" type="password" value="" />
1236
  </div>
1237
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1238
 
1239
- <div class="pw-actions">
1240
- <button class="btn" id="cancel" type="button">Cancel</button>
1241
- <button class="btn btn-primary" id="republish" type="button" disabled>Republish</button>
1242
- </div>
1243
- </div>
1244
- `;
1245
- document.body.appendChild(backdrop);
1246
-
1247
- let tagsLocal = tags.slice();
1248
-
1249
- const m = backdrop.querySelector("#m");
1250
- const h = backdrop.querySelector("#h");
1251
- const u = backdrop.querySelector("#u");
1252
- const l = backdrop.querySelector("#l");
1253
- const a = backdrop.querySelector("#a");
1254
- const pw = backdrop.querySelector("#pw");
1255
- const tagsEl = backdrop.querySelector("#tags");
1256
- const addTagBtn = backdrop.querySelector("#addTag");
1257
- const republishBtn = backdrop.querySelector("#republish");
1258
- const cancelBtn = backdrop.querySelector("#cancel");
1259
-
1260
- function close(){ backdrop.remove(); }
1261
-
1262
- function renderEditTags(){
1263
- tagsEl.innerHTML = "";
1264
- tagsLocal.forEach(t => {
1265
- const chip = document.createElement("span");
1266
- chip.className = "tag-chip";
1267
- chip.innerHTML = `${escapeHtml(t)} <span class="rm">×</span>`;
1268
- chip.addEventListener("click", (e) => {
1269
- e.preventDefault();
1270
- tagsLocal = tagsLocal.filter(x => x !== t);
1271
- renderEditTags();
1272
- renderFilterChips();
1273
- updateEnabled();
1274
- });
1275
- tagsEl.appendChild(chip);
1276
- });
1277
- }
1278
-
1279
- function updateEnabled(){
1280
- const ok = (pw.value || "").trim().length > 0
1281
- && (m.value || "").trim().length > 0
1282
- && (h.value || "").trim().length > 0
1283
- && (u.value || "").trim().length > 0;
1284
- republishBtn.disabled = !ok;
1285
- }
1286
-
1287
- addTagBtn.addEventListener("click", (e) => {
1288
- e.preventDefault();
1289
- e.stopPropagation();
1290
- if(!(m.value || "").trim()) return;
1291
- openTagDropdown(addTagBtn, tagsLocal, (t) => {
1292
- if(!tagsLocal.includes(t)) tagsLocal = [...tagsLocal, t];
1293
- renderEditTags();
1294
- renderFilterChips();
1295
- updateEnabled();
1296
- });
1297
- });
1298
-
1299
- [m,h,u,l,a,pw].forEach(el => el.addEventListener("input", updateEnabled));
1300
-
1301
- cancelBtn.addEventListener("click", (e) => { e.preventDefault(); close(); });
1302
- backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
1303
 
1304
- republishBtn.addEventListener("click", async (e) => {
1305
- e.preventDefault();
1306
- const password = (pw.value || "").trim();
1307
- const payload = {
1308
- password,
1309
- model: m.value || "",
1310
- tags: tagsLocal,
1311
- how_it_works: h.value || "",
1312
- use_cases: u.value || "",
1313
- link: l.value || "",
1314
- author: a.value || "",
1315
- };
1316
- try{
1317
  const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
1318
  method: "PUT",
1319
  body: JSON.stringify(payload),
1320
  headers: { "X-Password": password },
1321
  });
 
1322
  state.version = data.version;
1323
  state.published = data.cards || [];
1324
  showErr("");
1325
  renderFilterChips();
1326
  render();
1327
- close();
1328
- }catch(err){
1329
- showErr(String(err.message || err));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1330
  }
1331
  });
1332
-
1333
- renderEditTags();
1334
- updateEnabled();
1335
- pw.focus();
1336
  });
1337
 
1338
  grid.appendChild(card);
@@ -1387,7 +1525,7 @@ app_shiny = App(app_ui, shiny_server)
1387
 
1388
  routes = [
1389
  Route("/api/cards", endpoint=api_cards, methods=["GET", "POST"]),
1390
- Route("/api/cards/{card_id}", endpoint=api_card_id, methods=["PUT"]),
1391
  Mount("/", app=app_shiny),
1392
  ]
1393
 
 
139
 
140
  # -----------------------------------------------------------------------------
141
  # API: published cards only
142
+ # GET /api/cards
143
+ # POST /api/cards (password) -> publish new
144
+ # PUT /api/cards/{id} (password) -> republish existing
145
+ # DELETE /api/cards/{id} (password) -> delete published
146
  # -----------------------------------------------------------------------------
147
 
148
  async def api_cards(request: Request) -> Response:
 
160
  return _json({"error": msg}, status_code=401)
161
 
162
  card = _new_published_card(payload)
 
 
163
  if not card["model"] or not card["how_it_works"] or not card["use_cases"]:
164
  return _json({"error": "Model, How it Works, and Use Cases are required"}, status_code=400)
165
 
 
192
  return _json({"error": msg}, status_code=401)
193
 
194
  updated = _new_published_card(payload, card_id=card_id)
 
195
  if not updated["model"] or not updated["how_it_works"] or not updated["use_cases"]:
196
  return _json({"error": "Model, How it Works, and Use Cases are required"}, status_code=400)
197
 
 
200
  await set_state(state)
201
  return _json(state)
202
 
203
+ if request.method == "DELETE":
204
+ if not _check_password(request):
205
+ msg = "Unauthorized (check Space secret 'password')" if SERVER_PASSWORD else "Password not configured"
206
+ return _json({"error": msg}, status_code=401)
207
+
208
+ cards.pop(idx)
209
+ state["version"] += 1
210
+ await set_state(state)
211
+ return _json(state)
212
+
213
  return _json({"error": "Method not allowed"}, status_code=405)
214
 
215
 
216
  # -----------------------------------------------------------------------------
217
+ # UI
218
  # -----------------------------------------------------------------------------
219
 
220
  app_ui = ui.page_fluid(
 
231
  }
232
  body { background: #f3f6fb; }
233
 
234
+ /* Top bar */
235
  .topbar{
236
  background: linear-gradient(0deg, var(--navy2), var(--navy));
237
  padding: 12px 14px;
 
250
  min-width: 0;
251
  flex: 1 1 auto;
252
  }
 
253
  .chips-row{
254
  display:flex;
255
  gap: 8px;
 
316
  display:none;
317
  }
318
 
319
+ /* Grid */
320
  .cards-grid{
321
  display:grid;
322
  grid-template-columns: repeat(2, minmax(0, 1fr));
 
330
  .cards-grid{ grid-template-columns: 1fr; }
331
  }
332
 
333
+ /* Card */
334
  .card{
335
  background: var(--cardBg);
336
  border: 1px solid var(--cardBorder);
 
392
  padding-bottom: 2px;
393
  }
394
  .tag-bar::-webkit-scrollbar{ display:none; }
395
+
396
  .tag-chip{
397
  border: 1px solid #d8e1ee;
398
  background: #fff;
 
408
  white-space: nowrap;
409
  }
410
  .tag-chip .rm{ font-weight: 950; opacity: .6; }
411
+
412
+ .tag-btn{
413
  border: 1px dashed #cfd9ea;
414
  background: transparent;
415
  border-radius: 999px;
 
476
  min-height: 0;
477
  overflow:hidden;
478
  }
 
 
479
  .panel{
480
  display:flex;
481
  flex-direction:column;
 
558
  color: #41516b;
559
  }
560
 
561
+ /* Checklist popover for tags */
562
  .pop{
563
  position: fixed;
564
  background: white;
 
567
  box-shadow: 0 10px 30px rgba(0,0,0,.18);
568
  padding: 10px;
569
  z-index: 999999;
570
+ width: 340px;
571
  max-width: calc(100vw - 24px);
572
  }
573
  .pop h4{
 
587
  cursor:pointer;
588
  color:#7b879c;
589
  }
590
+ .pop .list{
591
+ max-height: 260px;
592
+ overflow:auto;
593
+ padding-right: 4px;
 
 
 
 
594
  }
595
+ .pop .checkrow{
596
+ display:flex;
597
+ align-items:center;
598
+ gap: 8px;
599
+ padding: 6px 6px;
600
+ border-radius: 10px;
601
+ }
602
+ .pop .checkrow:hover{ background:#f6f8fe; }
603
  .pop .row{
604
  display:flex;
605
+ gap: 8px;
606
+ align-items:center;
607
  margin-top: 10px;
608
  }
609
  .pop .btn{
 
615
  background: #ffffff;
616
  }
617
 
618
+ /* Generic modal shell (shorter, scrolls internally) */
619
+ .modal-backdrop{
620
  position: fixed;
621
  inset: 0;
622
  background: rgba(0,0,0,.35);
 
625
  justify-content:center;
626
  z-index: 999999;
627
  }
628
+ .modal{
629
+ width: min(620px, calc(100vw - 28px));
630
+ max-height: 85vh;
631
  background: white;
632
  border-radius: 14px;
633
  box-shadow: 0 10px 30px rgba(0,0,0,.25);
634
+ display:flex;
635
+ flex-direction:column;
636
+ overflow:hidden;
637
+ }
638
+ .modal .head{
639
+ padding: 14px 14px 10px 14px;
640
+ border-bottom: 1px solid #eef2f8;
641
+ }
642
+ .modal .head h3{ margin: 0 0 6px 0; font-size: 16px; }
643
+ .modal .muted{ color: var(--muted); font-size: 13px; }
644
+ .modal .body{
645
+ padding: 12px 14px;
646
+ overflow:auto;
647
+ }
648
+ .modal .foot{
649
+ padding: 12px 14px;
650
+ border-top: 1px solid #eef2f8;
651
+ display:flex;
652
+ gap: 10px;
653
+ justify-content:flex-end;
654
  }
 
 
 
655
  .btn{
656
  border-radius: 12px;
657
  padding: 9px 12px;
 
666
  color: var(--good);
667
  font-weight: 950;
668
  }
669
+ .btn-danger{
670
+ border: 1px solid #fecdd3;
671
+ background: #fff1f2;
672
+ color: #9f1239;
673
+ font-weight: 950;
674
+ }
675
+ .btn[disabled]{ opacity: .55; cursor: not-allowed; }
676
 
677
+ /* Edit form */
678
  .edit-grid{
679
  display:grid;
680
  grid-template-columns: 1fr;
681
  gap: 10px;
682
  }
 
 
 
683
  .edit-tags{
684
  display:flex;
 
685
  flex-wrap: wrap;
686
+ gap: 8px;
687
  align-items:center;
688
  }
 
689
  """
690
  ),
691
  ui.tags.div(
 
700
  ui.tags.script(
701
  r"""
702
  (() => {
703
+ const DRAFTS_KEY = "model_cards_drafts_v4";
704
 
705
  const grid = document.getElementById("grid");
706
  const addBtn = document.getElementById("add-btn");
 
841
  });
842
 
843
  // ------------------------------------------------------------
844
+ // Tag checklist popover
845
  // ------------------------------------------------------------
846
  function closePop(){
847
  if(openPop){ openPop.remove(); openPop = null; }
 
883
  return pop;
884
  }
885
 
886
+ function openTagChecklist(anchorEl, currentTags, onChange){
887
+ const all = tagUniverse();
 
 
 
 
888
  const html = `
889
  <button class="close" type="button">×</button>
890
+ <h4>Tags</h4>
891
+ <div class="list">
892
+ ${all.map(t => {
893
+ const checked = currentTags.includes(t) ? "checked" : "";
894
+ return `
895
+ <div class="checkrow">
896
+ <input type="checkbox" class="tg" data-tag="${escapeAttr(t)}" ${checked}/>
897
+ <div style="font-weight:850; font-size:12px; color:#1f2a44;">${escapeHtml(t)}</div>
898
+ </div>
899
+ `;
900
+ }).join("")}
901
+ </div>
902
+
903
  <div class="row">
904
+ <input id="custom" type="text" placeholder="Add custom tag…" />
905
+ <button class="btn" id="addCustom" type="button">Add</button>
906
  </div>
907
  `;
908
  const pop = openPopoverNear(anchorEl, html);
909
+
910
+ pop.querySelectorAll(".tg").forEach(cb => {
911
+ cb.addEventListener("change", () => {
912
+ const t = cb.getAttribute("data-tag");
913
+ if(!t) return;
914
+ const next = new Set(currentTags);
915
+ if(cb.checked) next.add(t);
916
+ else next.delete(t);
917
+ currentTags = Array.from(next);
918
+ onChange(currentTags);
919
+ });
920
+ });
921
+
922
+ const custom = pop.querySelector("#custom");
923
+ const addCustom = pop.querySelector("#addCustom");
924
+
925
+ function doAdd(){
926
+ const t = (custom.value || "").trim();
927
+ if(!t) return;
928
+ if(!currentTags.includes(t)){
929
+ currentTags = [...currentTags, t];
930
+ onChange(currentTags);
931
+ }
932
+ custom.value = "";
933
+ renderFilterChips(); // new tag becomes filterable
934
  closePop();
935
+ }
936
+
937
+ addCustom.addEventListener("click", doAdd);
938
+ custom.addEventListener("keydown", (e) => {
939
+ if(e.key === "Enter"){
940
+ e.preventDefault();
941
+ doAdd();
942
+ }
943
  });
944
+
945
+ custom.focus();
946
  }
947
 
948
  // ------------------------------------------------------------
949
+ // Generic modal helpers + Enter-to-confirm
950
  // ------------------------------------------------------------
951
+ function openModal({title, subtitle, bodyHtml, primaryText, primaryClass, isPrimaryEnabled, onPrimary, onMount}){
952
  const backdrop = document.createElement("div");
953
+ backdrop.className = "modal-backdrop";
954
  backdrop.innerHTML = `
955
+ <div class="modal" role="dialog" aria-modal="true">
956
+ <div class="head">
957
+ <h3>${escapeHtml(title)}</h3>
958
+ <div class="muted">${escapeHtml(subtitle || "")}</div>
959
+ </div>
960
+ <div class="body">${bodyHtml}</div>
961
+ <div class="foot">
 
962
  <button class="btn" id="cancel" type="button">Cancel</button>
963
+ <button class="btn ${primaryClass || ""}" id="primary" type="button" disabled>${escapeHtml(primaryText || "Confirm")}</button>
964
  </div>
965
  </div>
966
  `;
967
  document.body.appendChild(backdrop);
968
 
 
969
  const cancel = backdrop.querySelector("#cancel");
970
+ const primary = backdrop.querySelector("#primary");
 
 
 
 
 
971
 
972
  function close(){ backdrop.remove(); }
973
+
974
  cancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
975
  backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
976
 
977
+ async function doPrimary(){
978
+ if(primary.disabled) return;
979
+ try{ await onPrimary(); }
980
+ catch(e){ showErr(String(e.message || e)); }
981
+ close();
982
+ }
983
+
984
+ primary.addEventListener("click", (e) => { e.preventDefault(); doPrimary(); });
985
+
986
+ function setEnabled(ok){ primary.disabled = !ok; }
987
+ function refreshEnabled(){
988
+ try{ setEnabled(!!isPrimaryEnabled()); } catch { setEnabled(false); }
989
+ }
990
+
991
+ // Enter triggers primary if enabled
992
+ backdrop.addEventListener("keydown", (e) => {
993
+ if(e.key === "Enter"){
994
+ const active = document.activeElement;
995
+ // allow Enter in textarea without submitting
996
+ if(active && active.tagName === "TEXTAREA") return;
997
+ if(!primary.disabled){
998
+ e.preventDefault();
999
+ doPrimary();
1000
+ }
1001
+ }
1002
  });
1003
 
1004
+ if(onMount) onMount({backdrop, primary, refreshEnabled, close});
1005
+ refreshEnabled();
1006
+ return { close };
1007
  }
1008
 
1009
  // ------------------------------------------------------------
1010
  // Draft helpers
1011
  // ------------------------------------------------------------
1012
  function draftFilled(d){
 
1013
  return (d.model || "").trim().length > 0
1014
  && (d.how_it_works || "").trim().length > 0
1015
  && (d.use_cases || "").trim().length > 0;
 
1037
 
1038
  const modelText = (d.model || "").trim();
1039
  if(modelText) card.classList.add("has-title");
1040
+
1041
  let tags = Array.isArray(d.tags) ? d.tags.map(String) : [];
1042
 
1043
  card.innerHTML = `
 
1046
  <input class="title-input" type="text" value="${escapeHtml(d.model)}" placeholder="Model" />
1047
  <div class="tag-bar" style="display:${modelText ? "flex" : "none"};">
1048
  <span class="tags"></span>
1049
+ <button class="tag-btn" type="button">Tags</button>
1050
  </div>
1051
  </div>
1052
  <div class="hdr-right">
 
1087
  const linkWrap = card.querySelector(".linkwrap");
1088
  const tagBar = card.querySelector(".tag-bar");
1089
  const tagsWrap = card.querySelector(".tags");
1090
+ const tagBtn = card.querySelector(".tag-btn");
1091
  const postBtn = card.querySelector(".post-btn");
1092
  const delBtn = card.querySelector(".del-btn");
1093
 
1094
  function updateLink(){
1095
  const url = (linkEl.value || "").trim();
1096
+ linkWrap.innerHTML = url
1097
+ ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>`
1098
+ : "";
1099
  }
1100
 
1101
  function getDraft(){
 
1153
  }, 150));
1154
  }
1155
 
1156
+ // Tags checklist
1157
+ tagBtn.addEventListener("click", (e) => {
1158
  e.preventDefault();
1159
  e.stopPropagation();
1160
  if(!(titleEl.value || "").trim()) return;
1161
+ openTagChecklist(tagBtn, tags, (next) => {
1162
+ tags = next.slice();
1163
  setDraft({ tags });
1164
  renderTags();
1165
  renderFilterChips();
 
1167
  });
1168
  });
1169
 
1170
+ // Post (password modal; Enter works)
1171
+ postBtn.addEventListener("click", () => {
1172
  const cur = getDraft();
1173
  if(!cur || !draftFilled(cur)) return;
1174
 
1175
+ openModal({
1176
+ title: "Post this card?",
1177
+ subtitle: "This will publish the card for everyone to see.",
1178
+ bodyHtml: `
1179
+ <label>Password</label>
1180
+ <input type="password" id="pw" value="" />
1181
+ `,
1182
+ primaryText: "Post",
1183
+ primaryClass: "btn-primary",
1184
+ isPrimaryEnabled: () => {
1185
+ const pw = document.getElementById("pw");
1186
+ return pw && (pw.value || "").trim().length > 0;
1187
+ },
1188
+ onPrimary: async () => {
1189
+ const pw = document.getElementById("pw");
1190
+ const password = (pw.value || "").trim();
1191
  const payload = {
1192
  password,
1193
  model: cur.model,
 
1197
  link: cur.link,
1198
  author: cur.author,
1199
  };
1200
+ const data = await api("/api/cards", {
1201
+ method: "POST",
1202
+ body: JSON.stringify(payload),
1203
+ headers: { "X-Password": password },
1204
+ });
 
1205
 
1206
+ drafts = drafts.filter(x => x.id !== cur.id);
1207
+ saveDrafts();
 
1208
 
1209
+ state.version = data.version;
1210
+ state.published = data.cards;
 
1211
 
1212
+ showErr("");
1213
+ renderFilterChips();
1214
+ render();
1215
+ },
1216
+ onMount: ({backdrop, refreshEnabled}) => {
1217
+ const pw = backdrop.querySelector("#pw");
1218
+ pw.focus();
1219
+ pw.addEventListener("input", refreshEnabled);
1220
  }
1221
+ });
1222
  });
1223
 
1224
+ // Delete draft (local)
1225
  delBtn.addEventListener("click", () => {
1226
  if(!confirm("Delete this draft? (Only on your computer.)")) return;
1227
  drafts = drafts.filter(x => x.id !== d.id);
 
1230
  render();
1231
  });
1232
 
1233
+ // Enter should not submit from textareas; handled by modal only.
1234
  [titleEl, howEl, useEl, linkEl, byEl].forEach(el => el.addEventListener("input", scheduleDraftSave));
1235
 
1236
  updateLink();
 
1239
  grid.appendChild(card);
1240
  });
1241
 
1242
+ // ---------------- Published (shared) ----------------
1243
  visiblePublished.forEach((c) => {
1244
  const card = document.createElement("div");
1245
  card.className = "card";
 
1255
  </div>
1256
  <div class="hdr-right">
1257
  <button class="edit-btn" type="button">Edit</button>
1258
+ <button class="del-btn" type="button" title="Delete published">×</button>
1259
  </div>
1260
  </div>
1261
 
 
1280
 
1281
  const linkWrap = card.querySelector(".linkwrap");
1282
  const editBtn = card.querySelector(".edit-btn");
1283
+ const delBtn = card.querySelector(".del-btn");
1284
+
1285
  const url = (c.link || "").trim();
1286
+ linkWrap.innerHTML = url
1287
+ ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>`
1288
+ : "";
1289
+
1290
+ // Delete published (password + 2 checks; Enter works)
1291
+ delBtn.addEventListener("click", () => {
1292
+ openModal({
1293
+ title: "Delete published card?",
1294
+ subtitle: "This is permanent and affects everyone viewing this Space.",
1295
+ bodyHtml: `
1296
+ <div style="display:flex; flex-direction:column; gap:8px; margin-bottom:10px;">
1297
+ <label style="display:flex; gap:8px; align-items:center; font-weight:700; color:#1f2a44;">
1298
+ <input type="checkbox" id="c1" /> I understand this is permanent
1299
+ </label>
1300
+ <label style="display:flex; gap:8px; align-items:center; font-weight:700; color:#1f2a44;">
1301
+ <input type="checkbox" id="c2" /> I confirm I want to delete this card
1302
+ </label>
1303
+ </div>
1304
+ <label>Password</label>
1305
+ <input type="password" id="pw" value="" />
1306
+ `,
1307
+ primaryText: "Delete",
1308
+ primaryClass: "btn-danger",
1309
+ isPrimaryEnabled: () => {
1310
+ const c1 = document.getElementById("c1");
1311
+ const c2 = document.getElementById("c2");
1312
+ const pw = document.getElementById("pw");
1313
+ return c1?.checked && c2?.checked && pw && (pw.value || "").trim().length > 0;
1314
+ },
1315
+ onPrimary: async () => {
1316
+ const pw = document.getElementById("pw");
1317
+ const password = (pw.value || "").trim();
1318
+ const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
1319
+ method: "DELETE",
1320
+ headers: { "X-Password": password },
1321
+ });
1322
+ state.version = data.version;
1323
+ state.published = data.cards || [];
1324
+ showErr("");
1325
+ renderFilterChips();
1326
+ render();
1327
+ },
1328
+ onMount: ({backdrop, refreshEnabled}) => {
1329
+ const pw = backdrop.querySelector("#pw");
1330
+ const c1 = backdrop.querySelector("#c1");
1331
+ const c2 = backdrop.querySelector("#c2");
1332
+ pw.focus();
1333
+ [pw, c1, c2].forEach(el => el.addEventListener("input", refreshEnabled));
1334
+ [c1, c2].forEach(el => el.addEventListener("change", refreshEnabled));
1335
+ }
1336
+ });
1337
+ });
1338
 
1339
+ // Edit & Republish (short modal; internal scroll; tags checklist; Enter republish)
1340
  editBtn.addEventListener("click", () => {
1341
+ let tagsLocal = tags.slice();
 
 
 
 
 
 
1342
 
1343
+ openModal({
1344
+ title: "Edit & Republish",
1345
+ subtitle: "Edits go live only after you enter the password and republish.",
1346
+ bodyHtml: `
1347
  <div class="edit-grid">
1348
  <div>
1349
  <label>Model</label>
 
1354
  <label>Tags</label>
1355
  <div class="edit-tags" id="tags"></div>
1356
  <div style="margin-top:8px;">
1357
+ <button class="tag-btn" id="tagChecklist" type="button">Tags</button>
1358
  </div>
1359
  </div>
1360
 
 
1383
  <input id="pw" type="password" value="" />
1384
  </div>
1385
  </div>
1386
+ `,
1387
+ primaryText: "Republish",
1388
+ primaryClass: "btn-primary",
1389
+ isPrimaryEnabled: () => {
1390
+ const m = document.getElementById("m");
1391
+ const h = document.getElementById("h");
1392
+ const u = document.getElementById("u");
1393
+ const pw = document.getElementById("pw");
1394
+ return (pw?.value || "").trim().length > 0
1395
+ && (m?.value || "").trim().length > 0
1396
+ && (h?.value || "").trim().length > 0
1397
+ && (u?.value || "").trim().length > 0;
1398
+ },
1399
+ onPrimary: async () => {
1400
+ const m = document.getElementById("m");
1401
+ const h = document.getElementById("h");
1402
+ const u = document.getElementById("u");
1403
+ const l = document.getElementById("l");
1404
+ const a = document.getElementById("a");
1405
+ const pw = document.getElementById("pw");
1406
+ const password = (pw.value || "").trim();
1407
 
1408
+ const payload = {
1409
+ password,
1410
+ model: m.value || "",
1411
+ tags: tagsLocal,
1412
+ how_it_works: h.value || "",
1413
+ use_cases: u.value || "",
1414
+ link: l.value || "",
1415
+ author: a.value || "",
1416
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1418
  const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
1419
  method: "PUT",
1420
  body: JSON.stringify(payload),
1421
  headers: { "X-Password": password },
1422
  });
1423
+
1424
  state.version = data.version;
1425
  state.published = data.cards || [];
1426
  showErr("");
1427
  renderFilterChips();
1428
  render();
1429
+ },
1430
+ onMount: ({backdrop, refreshEnabled}) => {
1431
+ const tagsEl = backdrop.querySelector("#tags");
1432
+ const tagBtn = backdrop.querySelector("#tagChecklist");
1433
+ const pw = backdrop.querySelector("#pw");
1434
+
1435
+ function renderEditTags(){
1436
+ tagsEl.innerHTML = "";
1437
+ tagsLocal.forEach(t => {
1438
+ const chip = document.createElement("span");
1439
+ chip.className = "tag-chip";
1440
+ chip.innerHTML = `${escapeHtml(t)} <span class="rm">×</span>`;
1441
+ chip.addEventListener("click", (e) => {
1442
+ e.preventDefault();
1443
+ tagsLocal = tagsLocal.filter(x => x !== t);
1444
+ renderEditTags();
1445
+ renderFilterChips();
1446
+ refreshEnabled();
1447
+ });
1448
+ tagsEl.appendChild(chip);
1449
+ });
1450
+ }
1451
+
1452
+ tagBtn.addEventListener("click", (e) => {
1453
+ e.preventDefault();
1454
+ e.stopPropagation();
1455
+ openTagChecklist(tagBtn, tagsLocal, (next) => {
1456
+ tagsLocal = next.slice();
1457
+ renderEditTags();
1458
+ renderFilterChips();
1459
+ refreshEnabled();
1460
+ });
1461
+ });
1462
+
1463
+ // enable/disable republish as user types
1464
+ ["m","h","u","l","a","pw"].forEach(id => {
1465
+ const el = backdrop.querySelector("#" + id);
1466
+ el.addEventListener("input", refreshEnabled);
1467
+ });
1468
+
1469
+ pw.focus();
1470
+ renderEditTags();
1471
+ refreshEnabled();
1472
  }
1473
  });
 
 
 
 
1474
  });
1475
 
1476
  grid.appendChild(card);
 
1525
 
1526
  routes = [
1527
  Route("/api/cards", endpoint=api_cards, methods=["GET", "POST"]),
1528
+ Route("/api/cards/{card_id}", endpoint=api_card_id, methods=["PUT", "DELETE"]),
1529
  Mount("/", app=app_shiny),
1530
  ]
1531