Update app.py
Browse files
app.py
CHANGED
|
@@ -139,10 +139,6 @@ def _check_password(request: Request, payload: dict | None = None) -> bool:
|
|
| 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:
|
|
@@ -219,7 +215,7 @@ async def api_card_id(request: Request) -> Response:
|
|
| 219 |
|
| 220 |
app_ui = ui.page_fluid(
|
| 221 |
ui.tags.style(
|
| 222 |
-
"""
|
| 223 |
:root{
|
| 224 |
--navy:#062a4d;
|
| 225 |
--navy2:#041f3a;
|
|
@@ -231,6 +227,43 @@ 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));
|
|
@@ -278,7 +311,6 @@ app_ui = ui.page_fluid(
|
|
| 278 |
color: var(--navy);
|
| 279 |
border-color: rgba(0,0,0,.08);
|
| 280 |
}
|
| 281 |
-
|
| 282 |
#clear-filters{
|
| 283 |
border-radius: 12px;
|
| 284 |
padding: 9px 12px;
|
|
@@ -290,7 +322,6 @@ app_ui = ui.page_fluid(
|
|
| 290 |
color: #fff;
|
| 291 |
border: 1px solid rgba(255,255,255,.25);
|
| 292 |
}
|
| 293 |
-
|
| 294 |
#add-btn{
|
| 295 |
background: white;
|
| 296 |
color: var(--navy);
|
|
@@ -361,7 +392,6 @@ app_ui = ui.page_fluid(
|
|
| 361 |
flex: 1 1 auto;
|
| 362 |
min-width: 0;
|
| 363 |
}
|
| 364 |
-
|
| 365 |
.title-input, .title-text{
|
| 366 |
flex: 1 1 auto;
|
| 367 |
min-width: 140px;
|
|
@@ -392,7 +422,6 @@ app_ui = ui.page_fluid(
|
|
| 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,7 +437,6 @@ app_ui = ui.page_fluid(
|
|
| 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;
|
|
@@ -426,7 +454,6 @@ app_ui = ui.page_fluid(
|
|
| 426 |
gap: 8px;
|
| 427 |
flex: 0 0 auto;
|
| 428 |
}
|
| 429 |
-
|
| 430 |
.draft-badge{
|
| 431 |
font-size: 11px;
|
| 432 |
font-weight: 950;
|
|
@@ -437,7 +464,6 @@ app_ui = ui.page_fluid(
|
|
| 437 |
color: #9a3412;
|
| 438 |
user-select:none;
|
| 439 |
}
|
| 440 |
-
|
| 441 |
.post-btn, .edit-btn{
|
| 442 |
border-radius: 12px;
|
| 443 |
padding: 8px 12px;
|
|
@@ -445,17 +471,14 @@ app_ui = ui.page_fluid(
|
|
| 445 |
border: 1px solid rgba(0,0,0,.08);
|
| 446 |
cursor:pointer;
|
| 447 |
white-space:nowrap;
|
|
|
|
|
|
|
| 448 |
}
|
| 449 |
.post-btn{
|
| 450 |
background: #eaf7ef;
|
| 451 |
color: var(--good);
|
| 452 |
}
|
| 453 |
.post-btn:disabled{ opacity: .55; cursor: not-allowed; }
|
| 454 |
-
.edit-btn{
|
| 455 |
-
background: #ffffff;
|
| 456 |
-
color: #1f2a44;
|
| 457 |
-
}
|
| 458 |
-
|
| 459 |
.del-btn{
|
| 460 |
border: none;
|
| 461 |
background: transparent;
|
|
@@ -482,72 +505,50 @@ app_ui = ui.page_fluid(
|
|
| 482 |
flex: 1 1 0;
|
| 483 |
min-height: 0;
|
| 484 |
}
|
| 485 |
-
|
| 486 |
-
display:block;
|
| 487 |
-
font-size: 12px;
|
| 488 |
-
color: var(--muted);
|
| 489 |
-
margin: 0 0 5px;
|
| 490 |
-
font-weight: 750;
|
| 491 |
-
letter-spacing: .2px;
|
| 492 |
-
}
|
| 493 |
-
textarea{
|
| 494 |
-
width:100%;
|
| 495 |
-
box-sizing:border-box;
|
| 496 |
-
border: 1px solid #d8e1ee;
|
| 497 |
-
border-radius: 12px;
|
| 498 |
-
padding: 10px 12px;
|
| 499 |
-
font-size: 14px;
|
| 500 |
-
outline: none;
|
| 501 |
-
background: white;
|
| 502 |
flex: 1 1 0;
|
| 503 |
min-height: 0;
|
| 504 |
-
resize: none;
|
| 505 |
-
overflow: auto;
|
| 506 |
-
line-height: 1.25;
|
| 507 |
}
|
| 508 |
-
textarea[readonly]{ background:#f8fafc; }
|
| 509 |
|
| 510 |
.card-footer{
|
| 511 |
padding: 10px 12px 12px 12px;
|
| 512 |
border-top: 1px solid #eef2f8;
|
| 513 |
flex: 0 0 auto;
|
| 514 |
}
|
| 515 |
-
input{
|
| 516 |
-
width:100%;
|
| 517 |
-
box-sizing:border-box;
|
| 518 |
-
border: 1px solid #d8e1ee;
|
| 519 |
-
border-radius: 12px;
|
| 520 |
-
padding: 10px 12px;
|
| 521 |
-
font-size: 14px;
|
| 522 |
-
outline: none;
|
| 523 |
-
background: white;
|
| 524 |
-
}
|
| 525 |
-
input[readonly]{ background:#f8fafc; }
|
| 526 |
-
.resource-link{ display:inline-block; margin-top: 8px; font-size: 13px; }
|
| 527 |
|
| 528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
display:flex;
|
| 530 |
-
justify-content:flex-end;
|
| 531 |
align-items:center;
|
| 532 |
gap: 8px;
|
| 533 |
-
|
| 534 |
-
color:#1f2a44;
|
| 535 |
font-size: 12px;
|
| 536 |
-
font-weight:
|
|
|
|
| 537 |
}
|
| 538 |
.by-input{
|
| 539 |
-
width:
|
| 540 |
-
padding:
|
| 541 |
border-radius: 10px;
|
| 542 |
font-size: 12px;
|
| 543 |
}
|
| 544 |
.by-display{
|
|
|
|
| 545 |
text-align:right;
|
| 546 |
-
color:#1f2a44;
|
| 547 |
font-size: 12px;
|
| 548 |
font-weight: 900;
|
| 549 |
-
|
|
|
|
|
|
|
| 550 |
}
|
|
|
|
| 551 |
|
| 552 |
.empty{
|
| 553 |
margin: 0 16px;
|
|
@@ -558,65 +559,8 @@ app_ui = ui.page_fluid(
|
|
| 558 |
color: #41516b;
|
| 559 |
}
|
| 560 |
|
| 561 |
-
/*
|
| 562 |
-
.
|
| 563 |
-
position: fixed;
|
| 564 |
-
background: white;
|
| 565 |
-
border: 1px solid #d8e1ee;
|
| 566 |
-
border-radius: 14px;
|
| 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{
|
| 574 |
-
margin: 2px 0 10px 0;
|
| 575 |
-
font-size: 13px;
|
| 576 |
-
font-weight: 950;
|
| 577 |
-
color:#1f2a44;
|
| 578 |
-
}
|
| 579 |
-
.pop .close{
|
| 580 |
-
position:absolute;
|
| 581 |
-
top: 8px;
|
| 582 |
-
right: 10px;
|
| 583 |
-
border:none;
|
| 584 |
-
background:transparent;
|
| 585 |
-
font-size: 18px;
|
| 586 |
-
font-weight: 900;
|
| 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{
|
| 610 |
-
border-radius: 12px;
|
| 611 |
-
padding: 9px 12px;
|
| 612 |
-
font-weight: 900;
|
| 613 |
-
cursor:pointer;
|
| 614 |
-
border: 1px solid #d8e1ee;
|
| 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);
|
|
@@ -624,10 +568,11 @@ app_ui = ui.page_fluid(
|
|
| 624 |
align-items:center;
|
| 625 |
justify-content:center;
|
| 626 |
z-index: 999999;
|
|
|
|
| 627 |
}
|
| 628 |
-
.
|
| 629 |
-
width: min(
|
| 630 |
-
max-height:
|
| 631 |
background: white;
|
| 632 |
border-radius: 14px;
|
| 633 |
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
|
@@ -635,18 +580,18 @@ app_ui = ui.page_fluid(
|
|
| 635 |
flex-direction:column;
|
| 636 |
overflow:hidden;
|
| 637 |
}
|
| 638 |
-
.
|
| 639 |
-
padding:
|
| 640 |
border-bottom: 1px solid #eef2f8;
|
| 641 |
}
|
| 642 |
-
.
|
| 643 |
-
.
|
| 644 |
-
.
|
| 645 |
padding: 12px 14px;
|
| 646 |
overflow:auto;
|
| 647 |
}
|
| 648 |
-
.
|
| 649 |
-
padding: 12px 14px;
|
| 650 |
border-top: 1px solid #eef2f8;
|
| 651 |
display:flex;
|
| 652 |
gap: 10px;
|
|
@@ -655,7 +600,7 @@ app_ui = ui.page_fluid(
|
|
| 655 |
.btn{
|
| 656 |
border-radius: 12px;
|
| 657 |
padding: 9px 12px;
|
| 658 |
-
font-weight:
|
| 659 |
cursor:pointer;
|
| 660 |
border: 1px solid #d8e1ee;
|
| 661 |
background: #ffffff;
|
|
@@ -664,35 +609,51 @@ app_ui = ui.page_fluid(
|
|
| 664 |
background:#eaf7ef;
|
| 665 |
border-color:#bbf7d0;
|
| 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 |
-
/*
|
| 678 |
-
.
|
| 679 |
-
display:
|
| 680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
gap: 10px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
}
|
| 683 |
-
|
|
|
|
| 684 |
display:flex;
|
| 685 |
-
|
| 686 |
-
gap: 8px;
|
| 687 |
align-items:center;
|
|
|
|
| 688 |
}
|
| 689 |
"""
|
| 690 |
),
|
| 691 |
ui.tags.div(
|
| 692 |
{"class": "topbar"},
|
| 693 |
-
ui.tags.div(
|
| 694 |
-
|
| 695 |
-
|
|
|
|
|
|
|
| 696 |
ui.tags.button("+", {"id": "add-btn", "type": "button"}),
|
| 697 |
),
|
| 698 |
ui.tags.div({"id": "err", "class": "err"}),
|
|
@@ -700,7 +661,7 @@ app_ui = ui.page_fluid(
|
|
| 700 |
ui.tags.script(
|
| 701 |
r"""
|
| 702 |
(() => {
|
| 703 |
-
const DRAFTS_KEY = "
|
| 704 |
|
| 705 |
const grid = document.getElementById("grid");
|
| 706 |
const addBtn = document.getElementById("add-btn");
|
|
@@ -721,8 +682,6 @@ app_ui = ui.page_fluid(
|
|
| 721 |
const saveTimers = new Map();
|
| 722 |
const filterTags = new Set();
|
| 723 |
|
| 724 |
-
let openPop = null;
|
| 725 |
-
|
| 726 |
function showErr(msg){
|
| 727 |
if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; }
|
| 728 |
errBox.style.display="block";
|
|
@@ -841,169 +800,139 @@ app_ui = ui.page_fluid(
|
|
| 841 |
});
|
| 842 |
|
| 843 |
// ------------------------------------------------------------
|
| 844 |
-
//
|
| 845 |
-
// ------------------------------------------------------------
|
| 846 |
-
function closePop(){
|
| 847 |
-
if(openPop){ openPop.remove(); openPop = null; }
|
| 848 |
-
}
|
| 849 |
-
|
| 850 |
-
function openPopoverNear(anchorEl, html){
|
| 851 |
-
closePop();
|
| 852 |
-
const pop = document.createElement("div");
|
| 853 |
-
pop.className = "pop";
|
| 854 |
-
pop.innerHTML = html;
|
| 855 |
-
document.body.appendChild(pop);
|
| 856 |
-
openPop = pop;
|
| 857 |
-
|
| 858 |
-
const r = anchorEl.getBoundingClientRect();
|
| 859 |
-
const pad = 10;
|
| 860 |
-
let left = Math.min(r.left, window.innerWidth - pop.offsetWidth - pad);
|
| 861 |
-
left = Math.max(pad, left);
|
| 862 |
-
let top = r.bottom + 10;
|
| 863 |
-
if(top + pop.offsetHeight + pad > window.innerHeight){
|
| 864 |
-
top = Math.max(pad, r.top - pop.offsetHeight - 10);
|
| 865 |
-
}
|
| 866 |
-
pop.style.left = `${left}px`;
|
| 867 |
-
pop.style.top = `${top}px`;
|
| 868 |
-
|
| 869 |
-
const closeBtn = pop.querySelector(".close");
|
| 870 |
-
if(closeBtn) closeBtn.addEventListener("click", closePop);
|
| 871 |
-
|
| 872 |
-
setTimeout(() => {
|
| 873 |
-
const onDoc = (e) => {
|
| 874 |
-
if(!openPop) return;
|
| 875 |
-
if(openPop.contains(e.target)) return;
|
| 876 |
-
if(anchorEl.contains(e.target)) return;
|
| 877 |
-
closePop();
|
| 878 |
-
document.removeEventListener("mousedown", onDoc);
|
| 879 |
-
};
|
| 880 |
-
document.addEventListener("mousedown", onDoc);
|
| 881 |
-
}, 0);
|
| 882 |
-
|
| 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
|
| 952 |
const backdrop = document.createElement("div");
|
| 953 |
-
backdrop.className = "
|
| 954 |
backdrop.innerHTML = `
|
| 955 |
-
<div class="
|
| 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"
|
| 963 |
-
<button class="btn ${primaryClass || ""}"
|
| 964 |
</div>
|
| 965 |
</div>
|
| 966 |
`;
|
| 967 |
document.body.appendChild(backdrop);
|
| 968 |
|
| 969 |
-
const
|
| 970 |
-
const
|
|
|
|
| 971 |
|
| 972 |
function close(){ backdrop.remove(); }
|
| 973 |
|
| 974 |
-
|
| 975 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
|
| 977 |
-
async function
|
| 978 |
-
if(
|
| 979 |
-
try{
|
| 980 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
close();
|
| 982 |
}
|
| 983 |
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 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(!
|
| 998 |
e.preventDefault();
|
| 999 |
-
|
| 1000 |
}
|
| 1001 |
}
|
| 1002 |
});
|
| 1003 |
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
}
|
| 1008 |
|
| 1009 |
// ------------------------------------------------------------
|
|
@@ -1030,7 +959,7 @@ app_ui = ui.page_fluid(
|
|
| 1030 |
|
| 1031 |
grid.innerHTML = "";
|
| 1032 |
|
| 1033 |
-
// ---------------- Drafts
|
| 1034 |
visibleDrafts.forEach((d) => {
|
| 1035 |
const card = document.createElement("div");
|
| 1036 |
card.className = "card";
|
|
@@ -1069,12 +998,15 @@ app_ui = ui.page_fluid(
|
|
| 1069 |
|
| 1070 |
<div class="card-footer">
|
| 1071 |
<label>Link</label>
|
| 1072 |
-
<
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
<
|
| 1077 |
-
<
|
|
|
|
|
|
|
|
|
|
| 1078 |
</div>
|
| 1079 |
</div>
|
| 1080 |
`;
|
|
@@ -1153,26 +1085,29 @@ app_ui = ui.page_fluid(
|
|
| 1153 |
}, 150));
|
| 1154 |
}
|
| 1155 |
|
| 1156 |
-
// Tags
|
| 1157 |
tagBtn.addEventListener("click", (e) => {
|
| 1158 |
e.preventDefault();
|
| 1159 |
e.stopPropagation();
|
| 1160 |
if(!(titleEl.value || "").trim()) return;
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
|
|
|
|
|
|
|
|
|
| 1167 |
});
|
| 1168 |
});
|
| 1169 |
|
| 1170 |
-
// Post
|
| 1171 |
postBtn.addEventListener("click", () => {
|
| 1172 |
const cur = getDraft();
|
| 1173 |
if(!cur || !draftFilled(cur)) return;
|
| 1174 |
|
| 1175 |
-
|
| 1176 |
title: "Post this card?",
|
| 1177 |
subtitle: "This will publish the card for everyone to see.",
|
| 1178 |
bodyHtml: `
|
|
@@ -1181,13 +1116,9 @@ app_ui = ui.page_fluid(
|
|
| 1181 |
`,
|
| 1182 |
primaryText: "Post",
|
| 1183 |
primaryClass: "btn-primary",
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 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,
|
|
@@ -1213,10 +1144,10 @@ app_ui = ui.page_fluid(
|
|
| 1213 |
renderFilterChips();
|
| 1214 |
render();
|
| 1215 |
},
|
| 1216 |
-
|
| 1217 |
const pw = backdrop.querySelector("#pw");
|
| 1218 |
pw.focus();
|
| 1219 |
-
pw.addEventListener("input",
|
| 1220 |
}
|
| 1221 |
});
|
| 1222 |
});
|
|
@@ -1230,7 +1161,6 @@ app_ui = ui.page_fluid(
|
|
| 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,7 +1169,7 @@ app_ui = ui.page_fluid(
|
|
| 1239 |
grid.appendChild(card);
|
| 1240 |
});
|
| 1241 |
|
| 1242 |
-
// ---------------- Published
|
| 1243 |
visiblePublished.forEach((c) => {
|
| 1244 |
const card = document.createElement("div");
|
| 1245 |
card.className = "card";
|
|
@@ -1272,9 +1202,15 @@ app_ui = ui.page_fluid(
|
|
| 1272 |
|
| 1273 |
<div class="card-footer">
|
| 1274 |
<label>Link</label>
|
| 1275 |
-
<
|
| 1276 |
-
|
| 1277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
</div>
|
| 1279 |
`;
|
| 1280 |
|
|
@@ -1287,34 +1223,32 @@ app_ui = ui.page_fluid(
|
|
| 1287 |
? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>`
|
| 1288 |
: "";
|
| 1289 |
|
| 1290 |
-
// Delete published (
|
| 1291 |
delBtn.addEventListener("click", () => {
|
| 1292 |
-
|
| 1293 |
title: "Delete published card?",
|
| 1294 |
subtitle: "This is permanent and affects everyone viewing this Space.",
|
| 1295 |
bodyHtml: `
|
| 1296 |
-
<div
|
| 1297 |
-
<
|
| 1298 |
-
<input type="checkbox" id="c1"
|
| 1299 |
-
|
| 1300 |
-
<
|
| 1301 |
-
|
| 1302 |
-
|
|
|
|
|
|
|
| 1303 |
</div>
|
| 1304 |
<label>Password</label>
|
| 1305 |
<input type="password" id="pw" value="" />
|
| 1306 |
`,
|
| 1307 |
primaryText: "Delete",
|
| 1308 |
primaryClass: "btn-danger",
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 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 },
|
|
@@ -1325,26 +1259,26 @@ app_ui = ui.page_fluid(
|
|
| 1325 |
renderFilterChips();
|
| 1326 |
render();
|
| 1327 |
},
|
| 1328 |
-
|
| 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",
|
| 1334 |
-
[c1, c2].forEach(el => el.addEventListener("change",
|
| 1335 |
}
|
| 1336 |
});
|
| 1337 |
});
|
| 1338 |
|
| 1339 |
-
// Edit & Republish (
|
| 1340 |
editBtn.addEventListener("click", () => {
|
| 1341 |
let tagsLocal = tags.slice();
|
| 1342 |
|
| 1343 |
-
|
| 1344 |
title: "Edit & Republish",
|
| 1345 |
subtitle: "Edits go live only after you enter the password and republish.",
|
| 1346 |
bodyHtml: `
|
| 1347 |
-
<div
|
| 1348 |
<div>
|
| 1349 |
<label>Model</label>
|
| 1350 |
<input id="m" type="text" value="${escapeHtml(c.model || "")}" />
|
|
@@ -1352,30 +1286,31 @@ app_ui = ui.page_fluid(
|
|
| 1352 |
|
| 1353 |
<div>
|
| 1354 |
<label>Tags</label>
|
| 1355 |
-
<div
|
| 1356 |
<div style="margin-top:8px;">
|
| 1357 |
-
<button class="tag-btn" id="
|
| 1358 |
</div>
|
| 1359 |
</div>
|
| 1360 |
|
| 1361 |
<div>
|
| 1362 |
<label>How it Works</label>
|
| 1363 |
-
<textarea id="h">${escapeHtml(c.how_it_works || "")}</textarea>
|
| 1364 |
</div>
|
| 1365 |
|
| 1366 |
<div>
|
| 1367 |
<label>Use Cases</label>
|
| 1368 |
-
<textarea id="u">${escapeHtml(c.use_cases || "")}</textarea>
|
| 1369 |
-
</div>
|
| 1370 |
-
|
| 1371 |
-
<div>
|
| 1372 |
-
<label>Link</label>
|
| 1373 |
-
<input id="l" type="text" value="${escapeHtml(c.link || "")}" />
|
| 1374 |
</div>
|
| 1375 |
|
| 1376 |
-
<div
|
| 1377 |
-
<
|
| 1378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1379 |
</div>
|
| 1380 |
|
| 1381 |
<div>
|
|
@@ -1386,33 +1321,20 @@ app_ui = ui.page_fluid(
|
|
| 1386 |
`,
|
| 1387 |
primaryText: "Republish",
|
| 1388 |
primaryClass: "btn-primary",
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 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)}`, {
|
|
@@ -1427,13 +1349,13 @@ app_ui = ui.page_fluid(
|
|
| 1427 |
renderFilterChips();
|
| 1428 |
render();
|
| 1429 |
},
|
| 1430 |
-
|
| 1431 |
-
const
|
| 1432 |
-
const tagBtn = backdrop.querySelector("#
|
| 1433 |
const pw = backdrop.querySelector("#pw");
|
| 1434 |
|
| 1435 |
function renderEditTags(){
|
| 1436 |
-
|
| 1437 |
tagsLocal.forEach(t => {
|
| 1438 |
const chip = document.createElement("span");
|
| 1439 |
chip.className = "tag-chip";
|
|
@@ -1443,32 +1365,32 @@ app_ui = ui.page_fluid(
|
|
| 1443 |
tagsLocal = tagsLocal.filter(x => x !== t);
|
| 1444 |
renderEditTags();
|
| 1445 |
renderFilterChips();
|
| 1446 |
-
|
| 1447 |
});
|
| 1448 |
-
|
| 1449 |
});
|
| 1450 |
}
|
| 1451 |
|
| 1452 |
tagBtn.addEventListener("click", (e) => {
|
| 1453 |
e.preventDefault();
|
| 1454 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
|
|
|
|
|
|
| 1460 |
});
|
| 1461 |
});
|
| 1462 |
|
| 1463 |
-
// enable/disable republish as user types
|
| 1464 |
["m","h","u","l","a","pw"].forEach(id => {
|
| 1465 |
-
|
| 1466 |
-
el.addEventListener("input", refreshEnabled);
|
| 1467 |
});
|
| 1468 |
|
| 1469 |
pw.focus();
|
| 1470 |
renderEditTags();
|
| 1471 |
-
|
| 1472 |
}
|
| 1473 |
});
|
| 1474 |
});
|
|
|
|
| 139 |
|
| 140 |
# -----------------------------------------------------------------------------
|
| 141 |
# API: published cards only
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
# -----------------------------------------------------------------------------
|
| 143 |
|
| 144 |
async def api_cards(request: Request) -> Response:
|
|
|
|
| 215 |
|
| 216 |
app_ui = ui.page_fluid(
|
| 217 |
ui.tags.style(
|
| 218 |
+
r"""
|
| 219 |
:root{
|
| 220 |
--navy:#062a4d;
|
| 221 |
--navy2:#041f3a;
|
|
|
|
| 227 |
}
|
| 228 |
body { background: #f3f6fb; }
|
| 229 |
|
| 230 |
+
/* IMPORTANT: do NOT force checkboxes to width:100% */
|
| 231 |
+
input:not([type="checkbox"]):not([type="radio"]) {
|
| 232 |
+
width: 100%;
|
| 233 |
+
box-sizing: border-box;
|
| 234 |
+
border: 1px solid #d8e1ee;
|
| 235 |
+
border-radius: 12px;
|
| 236 |
+
padding: 10px 12px;
|
| 237 |
+
font-size: 14px;
|
| 238 |
+
outline: none;
|
| 239 |
+
background: white;
|
| 240 |
+
}
|
| 241 |
+
input[readonly]:not([type="checkbox"]):not([type="radio"]) { background:#f8fafc; }
|
| 242 |
+
|
| 243 |
+
textarea{
|
| 244 |
+
width:100%;
|
| 245 |
+
box-sizing:border-box;
|
| 246 |
+
border: 1px solid #d8e1ee;
|
| 247 |
+
border-radius: 12px;
|
| 248 |
+
padding: 10px 12px;
|
| 249 |
+
font-size: 14px;
|
| 250 |
+
outline: none;
|
| 251 |
+
background: white;
|
| 252 |
+
resize: none;
|
| 253 |
+
overflow: auto;
|
| 254 |
+
line-height: 1.25;
|
| 255 |
+
}
|
| 256 |
+
textarea[readonly]{ background:#f8fafc; }
|
| 257 |
+
|
| 258 |
+
label{
|
| 259 |
+
display:block;
|
| 260 |
+
font-size: 12px;
|
| 261 |
+
color: var(--muted);
|
| 262 |
+
margin: 0 0 5px;
|
| 263 |
+
font-weight: 750;
|
| 264 |
+
letter-spacing: .2px;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
/* Top bar */
|
| 268 |
.topbar{
|
| 269 |
background: linear-gradient(0deg, var(--navy2), var(--navy));
|
|
|
|
| 311 |
color: var(--navy);
|
| 312 |
border-color: rgba(0,0,0,.08);
|
| 313 |
}
|
|
|
|
| 314 |
#clear-filters{
|
| 315 |
border-radius: 12px;
|
| 316 |
padding: 9px 12px;
|
|
|
|
| 322 |
color: #fff;
|
| 323 |
border: 1px solid rgba(255,255,255,.25);
|
| 324 |
}
|
|
|
|
| 325 |
#add-btn{
|
| 326 |
background: white;
|
| 327 |
color: var(--navy);
|
|
|
|
| 392 |
flex: 1 1 auto;
|
| 393 |
min-width: 0;
|
| 394 |
}
|
|
|
|
| 395 |
.title-input, .title-text{
|
| 396 |
flex: 1 1 auto;
|
| 397 |
min-width: 140px;
|
|
|
|
| 422 |
padding-bottom: 2px;
|
| 423 |
}
|
| 424 |
.tag-bar::-webkit-scrollbar{ display:none; }
|
|
|
|
| 425 |
.tag-chip{
|
| 426 |
border: 1px solid #d8e1ee;
|
| 427 |
background: #fff;
|
|
|
|
| 437 |
white-space: nowrap;
|
| 438 |
}
|
| 439 |
.tag-chip .rm{ font-weight: 950; opacity: .6; }
|
|
|
|
| 440 |
.tag-btn{
|
| 441 |
border: 1px dashed #cfd9ea;
|
| 442 |
background: transparent;
|
|
|
|
| 454 |
gap: 8px;
|
| 455 |
flex: 0 0 auto;
|
| 456 |
}
|
|
|
|
| 457 |
.draft-badge{
|
| 458 |
font-size: 11px;
|
| 459 |
font-weight: 950;
|
|
|
|
| 464 |
color: #9a3412;
|
| 465 |
user-select:none;
|
| 466 |
}
|
|
|
|
| 467 |
.post-btn, .edit-btn{
|
| 468 |
border-radius: 12px;
|
| 469 |
padding: 8px 12px;
|
|
|
|
| 471 |
border: 1px solid rgba(0,0,0,.08);
|
| 472 |
cursor:pointer;
|
| 473 |
white-space:nowrap;
|
| 474 |
+
background: #fff;
|
| 475 |
+
color: #1f2a44;
|
| 476 |
}
|
| 477 |
.post-btn{
|
| 478 |
background: #eaf7ef;
|
| 479 |
color: var(--good);
|
| 480 |
}
|
| 481 |
.post-btn:disabled{ opacity: .55; cursor: not-allowed; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
.del-btn{
|
| 483 |
border: none;
|
| 484 |
background: transparent;
|
|
|
|
| 505 |
flex: 1 1 0;
|
| 506 |
min-height: 0;
|
| 507 |
}
|
| 508 |
+
.panel textarea{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
flex: 1 1 0;
|
| 510 |
min-height: 0;
|
|
|
|
|
|
|
|
|
|
| 511 |
}
|
|
|
|
| 512 |
|
| 513 |
.card-footer{
|
| 514 |
padding: 10px 12px 12px 12px;
|
| 515 |
border-top: 1px solid #eef2f8;
|
| 516 |
flex: 0 0 auto;
|
| 517 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
|
| 519 |
+
/* Link + By on same line */
|
| 520 |
+
.footer-row{
|
| 521 |
+
display:flex;
|
| 522 |
+
align-items:center;
|
| 523 |
+
gap: 10px;
|
| 524 |
+
}
|
| 525 |
+
.link-col{ flex: 1 1 auto; min-width: 0; }
|
| 526 |
+
.by-col{
|
| 527 |
+
flex: 0 0 auto;
|
| 528 |
display:flex;
|
|
|
|
| 529 |
align-items:center;
|
| 530 |
gap: 8px;
|
| 531 |
+
white-space: nowrap;
|
|
|
|
| 532 |
font-size: 12px;
|
| 533 |
+
font-weight: 900;
|
| 534 |
+
color:#1f2a44;
|
| 535 |
}
|
| 536 |
.by-input{
|
| 537 |
+
width: 160px;
|
| 538 |
+
padding: 8px 10px;
|
| 539 |
border-radius: 10px;
|
| 540 |
font-size: 12px;
|
| 541 |
}
|
| 542 |
.by-display{
|
| 543 |
+
width: 200px;
|
| 544 |
text-align:right;
|
|
|
|
| 545 |
font-size: 12px;
|
| 546 |
font-weight: 900;
|
| 547 |
+
color:#1f2a44;
|
| 548 |
+
overflow:hidden;
|
| 549 |
+
text-overflow: ellipsis;
|
| 550 |
}
|
| 551 |
+
.resource-link{ display:inline-block; margin-top: 8px; font-size: 13px; }
|
| 552 |
|
| 553 |
.empty{
|
| 554 |
margin: 0 16px;
|
|
|
|
| 559 |
color: #41516b;
|
| 560 |
}
|
| 561 |
|
| 562 |
+
/* Centered dialogs (renamed to avoid collisions) */
|
| 563 |
+
.dlg-backdrop{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
position: fixed;
|
| 565 |
inset: 0;
|
| 566 |
background: rgba(0,0,0,.35);
|
|
|
|
| 568 |
align-items:center;
|
| 569 |
justify-content:center;
|
| 570 |
z-index: 999999;
|
| 571 |
+
padding: 14px;
|
| 572 |
}
|
| 573 |
+
.dlg{
|
| 574 |
+
width: min(560px, calc(100vw - 28px));
|
| 575 |
+
max-height: 80vh;
|
| 576 |
background: white;
|
| 577 |
border-radius: 14px;
|
| 578 |
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
|
|
|
| 580 |
flex-direction:column;
|
| 581 |
overflow:hidden;
|
| 582 |
}
|
| 583 |
+
.dlg .head{
|
| 584 |
+
padding: 12px 14px 10px 14px;
|
| 585 |
border-bottom: 1px solid #eef2f8;
|
| 586 |
}
|
| 587 |
+
.dlg .head h3{ margin: 0 0 6px 0; font-size: 16px; }
|
| 588 |
+
.dlg .muted{ color: var(--muted); font-size: 13px; }
|
| 589 |
+
.dlg .body{
|
| 590 |
padding: 12px 14px;
|
| 591 |
overflow:auto;
|
| 592 |
}
|
| 593 |
+
.dlg .foot{
|
| 594 |
+
padding: 10px 14px 12px 14px;
|
| 595 |
border-top: 1px solid #eef2f8;
|
| 596 |
display:flex;
|
| 597 |
gap: 10px;
|
|
|
|
| 600 |
.btn{
|
| 601 |
border-radius: 12px;
|
| 602 |
padding: 9px 12px;
|
| 603 |
+
font-weight: 900;
|
| 604 |
cursor:pointer;
|
| 605 |
border: 1px solid #d8e1ee;
|
| 606 |
background: #ffffff;
|
|
|
|
| 609 |
background:#eaf7ef;
|
| 610 |
border-color:#bbf7d0;
|
| 611 |
color: var(--good);
|
|
|
|
| 612 |
}
|
| 613 |
.btn-danger{
|
| 614 |
border: 1px solid #fecdd3;
|
| 615 |
background: #fff1f2;
|
| 616 |
color: #9f1239;
|
|
|
|
| 617 |
}
|
| 618 |
.btn[disabled]{ opacity: .55; cursor: not-allowed; }
|
| 619 |
|
| 620 |
+
/* Checklist layout aligned */
|
| 621 |
+
.checklist{
|
| 622 |
+
display:flex;
|
| 623 |
+
flex-direction:column;
|
| 624 |
+
gap: 6px;
|
| 625 |
+
}
|
| 626 |
+
.checkrow{
|
| 627 |
+
display:flex;
|
| 628 |
+
align-items:center;
|
| 629 |
gap: 10px;
|
| 630 |
+
padding: 6px 8px;
|
| 631 |
+
border-radius: 10px;
|
| 632 |
+
}
|
| 633 |
+
.checkrow:hover{ background:#f6f8fe; }
|
| 634 |
+
.checkrow input[type="checkbox"]{ width:auto !important; }
|
| 635 |
+
.checkrow .lbl{
|
| 636 |
+
font-weight: 850;
|
| 637 |
+
font-size: 12px;
|
| 638 |
+
color:#1f2a44;
|
| 639 |
+
line-height: 1.1;
|
| 640 |
}
|
| 641 |
+
|
| 642 |
+
.row{
|
| 643 |
display:flex;
|
| 644 |
+
gap: 10px;
|
|
|
|
| 645 |
align-items:center;
|
| 646 |
+
margin-top: 10px;
|
| 647 |
}
|
| 648 |
"""
|
| 649 |
),
|
| 650 |
ui.tags.div(
|
| 651 |
{"class": "topbar"},
|
| 652 |
+
ui.tags.div(
|
| 653 |
+
{"class": "top-left"},
|
| 654 |
+
ui.tags.div({"id": "chips-row", "class": "chips-row"}),
|
| 655 |
+
ui.tags.button("Clear", {"id": "clear-filters", "type": "button"}),
|
| 656 |
+
),
|
| 657 |
ui.tags.button("+", {"id": "add-btn", "type": "button"}),
|
| 658 |
),
|
| 659 |
ui.tags.div({"id": "err", "class": "err"}),
|
|
|
|
| 661 |
ui.tags.script(
|
| 662 |
r"""
|
| 663 |
(() => {
|
| 664 |
+
const DRAFTS_KEY = "model_cards_drafts_v5";
|
| 665 |
|
| 666 |
const grid = document.getElementById("grid");
|
| 667 |
const addBtn = document.getElementById("add-btn");
|
|
|
|
| 682 |
const saveTimers = new Map();
|
| 683 |
const filterTags = new Set();
|
| 684 |
|
|
|
|
|
|
|
| 685 |
function showErr(msg){
|
| 686 |
if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; }
|
| 687 |
errBox.style.display="block";
|
|
|
|
| 800 |
});
|
| 801 |
|
| 802 |
// ------------------------------------------------------------
|
| 803 |
+
// Centered dialog helper (Enter triggers primary unless textarea)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
// ------------------------------------------------------------
|
| 805 |
+
function openDialog({title, subtitle, bodyHtml, primaryText, primaryClass, canSubmit, onSubmit, onOpen}){
|
| 806 |
const backdrop = document.createElement("div");
|
| 807 |
+
backdrop.className = "dlg-backdrop";
|
| 808 |
backdrop.innerHTML = `
|
| 809 |
+
<div class="dlg" role="dialog" aria-modal="true">
|
| 810 |
<div class="head">
|
| 811 |
<h3>${escapeHtml(title)}</h3>
|
| 812 |
<div class="muted">${escapeHtml(subtitle || "")}</div>
|
| 813 |
</div>
|
| 814 |
<div class="body">${bodyHtml}</div>
|
| 815 |
<div class="foot">
|
| 816 |
+
<button class="btn" data-act="cancel" type="button">Cancel</button>
|
| 817 |
+
<button class="btn ${primaryClass || ""}" data-act="primary" type="button" disabled>${escapeHtml(primaryText || "Confirm")}</button>
|
| 818 |
</div>
|
| 819 |
</div>
|
| 820 |
`;
|
| 821 |
document.body.appendChild(backdrop);
|
| 822 |
|
| 823 |
+
const dlg = backdrop.querySelector(".dlg");
|
| 824 |
+
const btnCancel = backdrop.querySelector('[data-act="cancel"]');
|
| 825 |
+
const btnPrimary = backdrop.querySelector('[data-act="primary"]');
|
| 826 |
|
| 827 |
function close(){ backdrop.remove(); }
|
| 828 |
|
| 829 |
+
function refresh(){
|
| 830 |
+
try{
|
| 831 |
+
btnPrimary.disabled = !(canSubmit ? canSubmit(backdrop) : true);
|
| 832 |
+
}catch{
|
| 833 |
+
btnPrimary.disabled = true;
|
| 834 |
+
}
|
| 835 |
+
}
|
| 836 |
|
| 837 |
+
async function submit(){
|
| 838 |
+
if(btnPrimary.disabled) return;
|
| 839 |
+
try{
|
| 840 |
+
await onSubmit(backdrop);
|
| 841 |
+
}catch(e){
|
| 842 |
+
showErr(String(e.message || e));
|
| 843 |
+
return;
|
| 844 |
+
}
|
| 845 |
close();
|
| 846 |
}
|
| 847 |
|
| 848 |
+
btnCancel.addEventListener("click", (e) => { e.preventDefault(); close(); });
|
| 849 |
+
backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); });
|
| 850 |
+
btnPrimary.addEventListener("click", (e) => { e.preventDefault(); submit(); });
|
|
|
|
|
|
|
|
|
|
| 851 |
|
|
|
|
| 852 |
backdrop.addEventListener("keydown", (e) => {
|
| 853 |
if(e.key === "Enter"){
|
| 854 |
const active = document.activeElement;
|
|
|
|
| 855 |
if(active && active.tagName === "TEXTAREA") return;
|
| 856 |
+
if(!btnPrimary.disabled){
|
| 857 |
e.preventDefault();
|
| 858 |
+
submit();
|
| 859 |
}
|
| 860 |
}
|
| 861 |
});
|
| 862 |
|
| 863 |
+
// initial
|
| 864 |
+
refresh();
|
| 865 |
+
if(onOpen) onOpen({backdrop, dlg, refresh, close});
|
| 866 |
+
return { close, refresh };
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
// ------------------------------------------------------------
|
| 870 |
+
// Tags checklist dialog (centered)
|
| 871 |
+
// ------------------------------------------------------------
|
| 872 |
+
function openTagsDialog({selected, onApply}){
|
| 873 |
+
const all = tagUniverse();
|
| 874 |
+
const selectedSet = new Set(selected);
|
| 875 |
+
|
| 876 |
+
const listHtml = all.map(t => {
|
| 877 |
+
const checked = selectedSet.has(t) ? "checked" : "";
|
| 878 |
+
return `
|
| 879 |
+
<div class="checkrow">
|
| 880 |
+
<input type="checkbox" class="tg" data-tag="${escapeAttr(t)}" ${checked}/>
|
| 881 |
+
<div class="lbl">${escapeHtml(t)}</div>
|
| 882 |
+
</div>
|
| 883 |
+
`;
|
| 884 |
+
}).join("");
|
| 885 |
+
|
| 886 |
+
openDialog({
|
| 887 |
+
title: "Tags",
|
| 888 |
+
subtitle: "Check all that apply.",
|
| 889 |
+
bodyHtml: `
|
| 890 |
+
<div class="checklist">${listHtml}</div>
|
| 891 |
+
<div class="row">
|
| 892 |
+
<input id="custom" type="text" placeholder="Add custom tag…" />
|
| 893 |
+
<button class="btn" id="addCustom" type="button">Add</button>
|
| 894 |
+
</div>
|
| 895 |
+
`,
|
| 896 |
+
primaryText: "Apply",
|
| 897 |
+
primaryClass: "btn-primary",
|
| 898 |
+
canSubmit: () => true,
|
| 899 |
+
onSubmit: async (backdrop) => {
|
| 900 |
+
const checked = [];
|
| 901 |
+
backdrop.querySelectorAll(".tg").forEach(cb => {
|
| 902 |
+
if(cb.checked) checked.push(cb.getAttribute("data-tag"));
|
| 903 |
+
});
|
| 904 |
+
onApply(checked.filter(Boolean));
|
| 905 |
+
},
|
| 906 |
+
onOpen: ({backdrop, refresh}) => {
|
| 907 |
+
// custom add
|
| 908 |
+
const custom = backdrop.querySelector("#custom");
|
| 909 |
+
const addBtn = backdrop.querySelector("#addCustom");
|
| 910 |
+
|
| 911 |
+
function addCustom(){
|
| 912 |
+
const t = (custom.value || "").trim();
|
| 913 |
+
if(!t) return;
|
| 914 |
+
// add to UI by appending a new row checked
|
| 915 |
+
const wrap = backdrop.querySelector(".checklist");
|
| 916 |
+
const row = document.createElement("div");
|
| 917 |
+
row.className = "checkrow";
|
| 918 |
+
row.innerHTML = `<input type="checkbox" class="tg" data-tag="${escapeAttr(t)}" checked/><div class="lbl">${escapeHtml(t)}</div>`;
|
| 919 |
+
wrap.prepend(row);
|
| 920 |
+
custom.value = "";
|
| 921 |
+
renderFilterChips();
|
| 922 |
+
refresh();
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
addBtn.addEventListener("click", (e) => { e.preventDefault(); addCustom(); });
|
| 926 |
+
custom.addEventListener("keydown", (e) => {
|
| 927 |
+
if(e.key === "Enter"){
|
| 928 |
+
e.preventDefault();
|
| 929 |
+
addCustom();
|
| 930 |
+
}
|
| 931 |
+
});
|
| 932 |
+
|
| 933 |
+
custom.focus();
|
| 934 |
+
}
|
| 935 |
+
});
|
| 936 |
}
|
| 937 |
|
| 938 |
// ------------------------------------------------------------
|
|
|
|
| 959 |
|
| 960 |
grid.innerHTML = "";
|
| 961 |
|
| 962 |
+
// ---------------- Drafts ----------------
|
| 963 |
visibleDrafts.forEach((d) => {
|
| 964 |
const card = document.createElement("div");
|
| 965 |
card.className = "card";
|
|
|
|
| 998 |
|
| 999 |
<div class="card-footer">
|
| 1000 |
<label>Link</label>
|
| 1001 |
+
<div class="footer-row">
|
| 1002 |
+
<div class="link-col">
|
| 1003 |
+
<input type="text" class="link" value="${escapeHtml(d.link)}" placeholder="https://..." />
|
| 1004 |
+
<div class="linkwrap"></div>
|
| 1005 |
+
</div>
|
| 1006 |
+
<div class="by-col">
|
| 1007 |
+
<span>By:</span>
|
| 1008 |
+
<input class="by-input" type="text" value="${escapeHtml(d.author)}" placeholder="name" />
|
| 1009 |
+
</div>
|
| 1010 |
</div>
|
| 1011 |
</div>
|
| 1012 |
`;
|
|
|
|
| 1085 |
}, 150));
|
| 1086 |
}
|
| 1087 |
|
| 1088 |
+
// Tags dialog
|
| 1089 |
tagBtn.addEventListener("click", (e) => {
|
| 1090 |
e.preventDefault();
|
| 1091 |
e.stopPropagation();
|
| 1092 |
if(!(titleEl.value || "").trim()) return;
|
| 1093 |
+
openTagsDialog({
|
| 1094 |
+
selected: tags,
|
| 1095 |
+
onApply: (next) => {
|
| 1096 |
+
tags = next.slice();
|
| 1097 |
+
setDraft({ tags });
|
| 1098 |
+
renderTags();
|
| 1099 |
+
renderFilterChips();
|
| 1100 |
+
updateHeader();
|
| 1101 |
+
}
|
| 1102 |
});
|
| 1103 |
});
|
| 1104 |
|
| 1105 |
+
// Post dialog
|
| 1106 |
postBtn.addEventListener("click", () => {
|
| 1107 |
const cur = getDraft();
|
| 1108 |
if(!cur || !draftFilled(cur)) return;
|
| 1109 |
|
| 1110 |
+
openDialog({
|
| 1111 |
title: "Post this card?",
|
| 1112 |
subtitle: "This will publish the card for everyone to see.",
|
| 1113 |
bodyHtml: `
|
|
|
|
| 1116 |
`,
|
| 1117 |
primaryText: "Post",
|
| 1118 |
primaryClass: "btn-primary",
|
| 1119 |
+
canSubmit: (backdrop) => (backdrop.querySelector("#pw").value || "").trim().length > 0,
|
| 1120 |
+
onSubmit: async (backdrop) => {
|
| 1121 |
+
const password = (backdrop.querySelector("#pw").value || "").trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1122 |
const payload = {
|
| 1123 |
password,
|
| 1124 |
model: cur.model,
|
|
|
|
| 1144 |
renderFilterChips();
|
| 1145 |
render();
|
| 1146 |
},
|
| 1147 |
+
onOpen: ({backdrop, refresh}) => {
|
| 1148 |
const pw = backdrop.querySelector("#pw");
|
| 1149 |
pw.focus();
|
| 1150 |
+
pw.addEventListener("input", refresh);
|
| 1151 |
}
|
| 1152 |
});
|
| 1153 |
});
|
|
|
|
| 1161 |
render();
|
| 1162 |
});
|
| 1163 |
|
|
|
|
| 1164 |
[titleEl, howEl, useEl, linkEl, byEl].forEach(el => el.addEventListener("input", scheduleDraftSave));
|
| 1165 |
|
| 1166 |
updateLink();
|
|
|
|
| 1169 |
grid.appendChild(card);
|
| 1170 |
});
|
| 1171 |
|
| 1172 |
+
// ---------------- Published ----------------
|
| 1173 |
visiblePublished.forEach((c) => {
|
| 1174 |
const card = document.createElement("div");
|
| 1175 |
card.className = "card";
|
|
|
|
| 1202 |
|
| 1203 |
<div class="card-footer">
|
| 1204 |
<label>Link</label>
|
| 1205 |
+
<div class="footer-row">
|
| 1206 |
+
<div class="link-col">
|
| 1207 |
+
<input readonly value="${escapeHtml(c.link || "")}" />
|
| 1208 |
+
<div class="linkwrap"></div>
|
| 1209 |
+
</div>
|
| 1210 |
+
<div class="by-col">
|
| 1211 |
+
<div class="by-display">${c.author ? ("By: " + escapeHtml(c.author)) : ""}</div>
|
| 1212 |
+
</div>
|
| 1213 |
+
</div>
|
| 1214 |
</div>
|
| 1215 |
`;
|
| 1216 |
|
|
|
|
| 1223 |
? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>`
|
| 1224 |
: "";
|
| 1225 |
|
| 1226 |
+
// Delete published (centered + compact)
|
| 1227 |
delBtn.addEventListener("click", () => {
|
| 1228 |
+
openDialog({
|
| 1229 |
title: "Delete published card?",
|
| 1230 |
subtitle: "This is permanent and affects everyone viewing this Space.",
|
| 1231 |
bodyHtml: `
|
| 1232 |
+
<div class="checklist" style="margin-bottom:10px;">
|
| 1233 |
+
<div class="checkrow">
|
| 1234 |
+
<input type="checkbox" id="c1"/>
|
| 1235 |
+
<div class="lbl">I understand this is permanent</div>
|
| 1236 |
+
</div>
|
| 1237 |
+
<div class="checkrow">
|
| 1238 |
+
<input type="checkbox" id="c2"/>
|
| 1239 |
+
<div class="lbl">I confirm I want to delete this card</div>
|
| 1240 |
+
</div>
|
| 1241 |
</div>
|
| 1242 |
<label>Password</label>
|
| 1243 |
<input type="password" id="pw" value="" />
|
| 1244 |
`,
|
| 1245 |
primaryText: "Delete",
|
| 1246 |
primaryClass: "btn-danger",
|
| 1247 |
+
canSubmit: (b) => b.querySelector("#c1").checked
|
| 1248 |
+
&& b.querySelector("#c2").checked
|
| 1249 |
+
&& (b.querySelector("#pw").value || "").trim().length > 0,
|
| 1250 |
+
onSubmit: async (b) => {
|
| 1251 |
+
const password = (b.querySelector("#pw").value || "").trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
|
| 1253 |
method: "DELETE",
|
| 1254 |
headers: { "X-Password": password },
|
|
|
|
| 1259 |
renderFilterChips();
|
| 1260 |
render();
|
| 1261 |
},
|
| 1262 |
+
onOpen: ({backdrop, refresh}) => {
|
| 1263 |
const pw = backdrop.querySelector("#pw");
|
| 1264 |
const c1 = backdrop.querySelector("#c1");
|
| 1265 |
const c2 = backdrop.querySelector("#c2");
|
| 1266 |
pw.focus();
|
| 1267 |
+
[pw, c1, c2].forEach(el => el.addEventListener("input", refresh));
|
| 1268 |
+
[c1, c2].forEach(el => el.addEventListener("change", refresh));
|
| 1269 |
}
|
| 1270 |
});
|
| 1271 |
});
|
| 1272 |
|
| 1273 |
+
// Edit & Republish (centered, shorter, internal scroll)
|
| 1274 |
editBtn.addEventListener("click", () => {
|
| 1275 |
let tagsLocal = tags.slice();
|
| 1276 |
|
| 1277 |
+
openDialog({
|
| 1278 |
title: "Edit & Republish",
|
| 1279 |
subtitle: "Edits go live only after you enter the password and republish.",
|
| 1280 |
bodyHtml: `
|
| 1281 |
+
<div style="display:grid; gap:10px;">
|
| 1282 |
<div>
|
| 1283 |
<label>Model</label>
|
| 1284 |
<input id="m" type="text" value="${escapeHtml(c.model || "")}" />
|
|
|
|
| 1286 |
|
| 1287 |
<div>
|
| 1288 |
<label>Tags</label>
|
| 1289 |
+
<div id="tags" style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;"></div>
|
| 1290 |
<div style="margin-top:8px;">
|
| 1291 |
+
<button class="tag-btn" id="tagBtn" type="button">Tags</button>
|
| 1292 |
</div>
|
| 1293 |
</div>
|
| 1294 |
|
| 1295 |
<div>
|
| 1296 |
<label>How it Works</label>
|
| 1297 |
+
<textarea id="h" style="height:90px;">${escapeHtml(c.how_it_works || "")}</textarea>
|
| 1298 |
</div>
|
| 1299 |
|
| 1300 |
<div>
|
| 1301 |
<label>Use Cases</label>
|
| 1302 |
+
<textarea id="u" style="height:90px;">${escapeHtml(c.use_cases || "")}</textarea>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1303 |
</div>
|
| 1304 |
|
| 1305 |
+
<div class="footer-row" style="align-items:flex-end;">
|
| 1306 |
+
<div class="link-col">
|
| 1307 |
+
<label>Link</label>
|
| 1308 |
+
<input id="l" type="text" value="${escapeHtml(c.link || "")}" />
|
| 1309 |
+
</div>
|
| 1310 |
+
<div class="by-col" style="padding-bottom:2px;">
|
| 1311 |
+
<span>By:</span>
|
| 1312 |
+
<input id="a" class="by-input" type="text" value="${escapeHtml(c.author || "")}" placeholder="name" />
|
| 1313 |
+
</div>
|
| 1314 |
</div>
|
| 1315 |
|
| 1316 |
<div>
|
|
|
|
| 1321 |
`,
|
| 1322 |
primaryText: "Republish",
|
| 1323 |
primaryClass: "btn-primary",
|
| 1324 |
+
canSubmit: (b) => (b.querySelector("#pw").value || "").trim().length > 0
|
| 1325 |
+
&& (b.querySelector("#m").value || "").trim().length > 0
|
| 1326 |
+
&& (b.querySelector("#h").value || "").trim().length > 0
|
| 1327 |
+
&& (b.querySelector("#u").value || "").trim().length > 0,
|
| 1328 |
+
onSubmit: async (b) => {
|
| 1329 |
+
const password = (b.querySelector("#pw").value || "").trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1330 |
const payload = {
|
| 1331 |
password,
|
| 1332 |
+
model: b.querySelector("#m").value || "",
|
| 1333 |
tags: tagsLocal,
|
| 1334 |
+
how_it_works: b.querySelector("#h").value || "",
|
| 1335 |
+
use_cases: b.querySelector("#u").value || "",
|
| 1336 |
+
link: b.querySelector("#l").value || "",
|
| 1337 |
+
author: b.querySelector("#a").value || "",
|
| 1338 |
};
|
| 1339 |
|
| 1340 |
const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, {
|
|
|
|
| 1349 |
renderFilterChips();
|
| 1350 |
render();
|
| 1351 |
},
|
| 1352 |
+
onOpen: ({backdrop, refresh}) => {
|
| 1353 |
+
const tagsWrap = backdrop.querySelector("#tags");
|
| 1354 |
+
const tagBtn = backdrop.querySelector("#tagBtn");
|
| 1355 |
const pw = backdrop.querySelector("#pw");
|
| 1356 |
|
| 1357 |
function renderEditTags(){
|
| 1358 |
+
tagsWrap.innerHTML = "";
|
| 1359 |
tagsLocal.forEach(t => {
|
| 1360 |
const chip = document.createElement("span");
|
| 1361 |
chip.className = "tag-chip";
|
|
|
|
| 1365 |
tagsLocal = tagsLocal.filter(x => x !== t);
|
| 1366 |
renderEditTags();
|
| 1367 |
renderFilterChips();
|
| 1368 |
+
refresh();
|
| 1369 |
});
|
| 1370 |
+
tagsWrap.appendChild(chip);
|
| 1371 |
});
|
| 1372 |
}
|
| 1373 |
|
| 1374 |
tagBtn.addEventListener("click", (e) => {
|
| 1375 |
e.preventDefault();
|
| 1376 |
+
openTagsDialog({
|
| 1377 |
+
selected: tagsLocal,
|
| 1378 |
+
onApply: (next) => {
|
| 1379 |
+
tagsLocal = next.slice();
|
| 1380 |
+
renderEditTags();
|
| 1381 |
+
renderFilterChips();
|
| 1382 |
+
refresh();
|
| 1383 |
+
}
|
| 1384 |
});
|
| 1385 |
});
|
| 1386 |
|
|
|
|
| 1387 |
["m","h","u","l","a","pw"].forEach(id => {
|
| 1388 |
+
backdrop.querySelector("#" + id).addEventListener("input", refresh);
|
|
|
|
| 1389 |
});
|
| 1390 |
|
| 1391 |
pw.focus();
|
| 1392 |
renderEditTags();
|
| 1393 |
+
refresh();
|
| 1394 |
}
|
| 1395 |
});
|
| 1396 |
});
|