From ccbe243eab9e906acaf266710a2a3660d4440cc0 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sat, 2 May 2026 12:11:12 -0500 Subject: [PATCH] share: render markdown + preview-before-publish + soul-history probe Share card now displays the AI bubble's marked-rendered HTML (after basic tag allowlist sanitization) instead of escaped plaintext. Markdown bold, lists, code, headers all show. Share click in chat now opens a preview modal. Publishing to the gallery only happens when the user explicitly clicks Publish in the modal - removes the click-and-immediately-public surprise. --- scripts/restore-chat-js-with-preview.py | 457 ++++++++++++++++++++++++ src/assets/js/37b5ead0d425.js | 1 + src/assets/js/fc247ef45b1d.js | 1 - src/assets/js/manifest.json | 25 +- src/main.el | 362 ++++++++++++++++--- src/styles.el | 22 +- 6 files changed, 812 insertions(+), 56 deletions(-) create mode 100644 scripts/restore-chat-js-with-preview.py create mode 100644 src/assets/js/37b5ead0d425.js delete mode 100644 src/assets/js/fc247ef45b1d.js diff --git a/scripts/restore-chat-js-with-preview.py b/scripts/restore-chat-js-with-preview.py new file mode 100644 index 0000000..25880e7 --- /dev/null +++ b/scripts/restore-chat-js-with-preview.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +""" +restore-chat-js-with-preview.py - Re-inline the chat-widget JS into +styles.el's page_close() so that extract-js.py picks up the freshly modified +source on the next build (instead of carrying the obfuscated old asset +forward forever). + +What this writes: + - The original chat-widget IIFE (from commit 640813e^), modified to + capture bubble.innerHTML on Share-click and open a preview modal + instead of POSTing to /api/share immediately. + - Modal HTML (#neuron-share-preview-modal) inserted right before the + chat-widget script. + - The /api/share POST in publishSharePreview() sends both answer (legacy + plaintext), answer_html (rendered, sanitized server-side), and + answer_plaintext (og:desc). + +Idempotent. Re-run is a no-op once the inline block is present. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +STYLES_EL = REPO_ROOT / "src" / "styles.el" + +MARKER = "neuron-share-preview-modal" + +# El strings use \" for embedded ". Newlines stay literal. Backticks fine. +# We assemble the inline JS as a single literal: opening . +# Every double-quote inside must become \\" in the El source. We write it as a +# raw string and use string substitution at the end. + +CHAT_HTML_AND_JS = r""" + +
+
+
+
+
Preview
+
This is what you are about to publish
+
+ +
+ +
+ + +
+
+
+ + +""" + +# Replace the line `` +# with our new inline content + script. extract-js will then re-extract this +# fresh inline block to a content-hashed asset on the next build. + +OLD_LINE = '' + + +def main(): + src = STYLES_EL.read_text(encoding="utf-8") + if MARKER in src: + print("styles.el already contains the share preview modal - skipping") + return + if OLD_LINE not in src: + # Maybe extract-js already pulled out a different hash; fail loud. + print("ERROR: anchor `", "/script-->"), "", "/script-->") + let s3: String = str_replace(str_replace(s2, "", "/iframe-->"), "", "/iframe-->") + let s5: String = str_replace(str_replace(s4, "", "/style-->"), "", "/style-->") + let s7: String = str_replace(str_replace(s6, "", "/object-->"), "", "/object-->") + let s9: String = str_replace(str_replace(s8, "", "/form-->") + let s12: String = str_replace(str_replace(s11, " String { let q_html: String = str_replace(str_replace(str_replace(question, "&", "&"), "<", "<"), ">", ">") - let a_html: String = str_replace(str_replace(str_replace(answer, "&", "&"), "<", "<"), ">", ">") + // answer_html_in is sanitized, marked.js-rendered HTML. Fall back to + // escaped plaintext when the caller didn't supply rendered HTML (legacy). + let a_html: String = if str_eq(answer_html_in, "") { + str_replace(str_replace(str_replace(answer_plain, "&", "&"), "<", "<"), ">", ">") + } else { + sanitize_share_html(answer_html_in) + } + // Use plaintext for og:description so social previews are readable. + let answer: String = answer_plain let og_desc: String = str_slice(answer, 0, 140) let base_url: String = state_get("__neuron_origin__") let card_url: String = base_url + "/share/" + id @@ -217,7 +278,20 @@ body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;bac .chat-row-user{display:flex;flex-direction:row-reverse} .chat-row-ai{display:flex;flex-direction:row;align-items:flex-end;gap:.625rem} .bubble-user{background:#0052A0;color:#fff;border-radius:18px 18px 4px 18px;padding:11px 15px;max-width:78%;font-size:.875rem;line-height:1.55;word-break:break-word} -.bubble-ai{background:var(--bg);color:var(--t1);border:1px solid rgba(0,0,0,.07);border-radius:18px 18px 18px 4px;padding:11px 15px;max-width:88%;font-size:.875rem;font-weight:300;line-height:1.65;word-break:break-word;white-space:pre-wrap;box-shadow:0 2px 6px rgba(0,0,0,.05)} +.bubble-ai{background:var(--bg);color:var(--t1);border:1px solid rgba(0,0,0,.07);border-radius:18px 18px 18px 4px;padding:11px 15px;max-width:88%;font-size:.875rem;font-weight:300;line-height:1.65;word-break:break-word;box-shadow:0 2px 6px rgba(0,0,0,.05)} +.bubble-ai p{margin:0} +.bubble-ai p+p{margin-top:.6rem} +.bubble-ai ul,.bubble-ai ol{margin:.5rem 0 .5rem 1.25rem;padding:0} +.bubble-ai li+li{margin-top:.25rem} +.bubble-ai strong{font-weight:600} +.bubble-ai em{font-style:italic} +.bubble-ai code{font-family:'IBM Plex Mono','Menlo',monospace;font-size:.8rem;background:rgba(0,0,0,.05);padding:1px 4px;border-radius:3px} +.bubble-ai pre{background:rgba(0,0,0,.05);padding:.75rem;border-radius:6px;overflow-x:auto;font-size:.8rem;margin:.5rem 0} +.bubble-ai pre code{background:none;padding:0} +.bubble-ai blockquote{border-left:3px solid rgba(0,82,160,.3);margin:.5rem 0;padding:.25rem 0 .25rem .75rem;color:var(--t2)} +.bubble-ai h1,.bubble-ai h2,.bubble-ai h3,.bubble-ai h4{font-weight:600;margin:.5rem 0 .25rem} +.bubble-ai h1{font-size:1.05rem}.bubble-ai h2{font-size:1rem}.bubble-ai h3{font-size:.95rem}.bubble-ai h4{font-size:.9rem} +.bubble-ai a{color:var(--navy);text-decoration:underline} .ai-col{display:flex;flex-direction:column;gap:.25rem} .ai-label{font-size:.6rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--navy)} .avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;background:#fff;border:1px solid rgba(0,82,160,.15);display:flex;align-items:center;justify-content:center} @@ -1018,19 +1092,116 @@ fn handle_request(method: String, path: String, body: String) -> String { return "{\"received\":true}" } + // ── DocuSeal webhook — POST /api/docuseal/webhook/ ──────────────── + // Path-token-authenticated receiver. Persists every event to Supabase + // docuseal_events (full body as jsonb) and emails Will via Resend on + // form.completed or form.declined. Token comes from DOCUSEAL_WEBHOOK_TOKEN + // env var (mounted from Secret Manager). No HMAC yet — DocuSeal does not + // currently expose a per-webhook signing secret. + if str_starts_with(path, "/api/docuseal/webhook/") { + if !str_eq(method, "POST") { + return "{\"error\":\"POST required\"}" + } + let token_in_path: String = str_slice(path, 22, str_len(path)) + let expected_token: String = env("DOCUSEAL_WEBHOOK_TOKEN") + if str_eq(expected_token, "") || !str_eq(token_in_path, expected_token) { + return "{\"__status__\":401,\"error\":\"unauthorized\"}" + } + + let event_type: String = json_get(body, "event_type") + let event_ts: String = json_get(body, "timestamp") + let data_raw: String = json_get_raw(body, "data") + let sub_id: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "submission_id") } + let signer_email: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "email") } + let signer_name: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "name") } + let signer_ua: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "ua") } + let signer_ip: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "ip") } + println("[docuseal] event=" + event_type + " sub=" + sub_id + " email=" + signer_email) + + let body_safe: String = str_replace(str_replace(str_replace(str_replace(body, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r") + let name_safe: String = str_replace(str_replace(signer_name, "\\", "\\\\"), "\"", "\\\"") + let ua_safe: String = str_replace(str_replace(signer_ua, "\\", "\\\\"), "\"", "\\\"") + + let sb_url: String = state_get("__supabase_project_url__") + let sb_key: String = state_get("__supabase_service_key__") + if !str_eq(sb_key, "") { + let sub_field: String = if str_eq(sub_id, "") { "null" } else { sub_id } + let email_field: String = if str_eq(signer_email, "") { "null" } else { "\"" + signer_email + "\"" } + let name_field: String = if str_eq(signer_name, "") { "null" } else { "\"" + name_safe + "\"" } + let ua_field: String = if str_eq(signer_ua, "") { "null" } else { "\"" + ua_safe + "\"" } + let ip_field: String = if str_eq(signer_ip, "") { "null" } else { "\"" + signer_ip + "\"" } + let ts_field: String = if str_eq(event_ts, "") { "null" } else { "\"" + event_ts + "\"" } + let row: String = "{\"event_type\":\"" + event_type + + "\",\"event_timestamp\":" + ts_field + + ",\"submission_id\":" + sub_field + + ",\"signer_email\":" + email_field + + ",\"signer_name\":" + name_field + + ",\"ua\":" + ua_field + + ",\"ip\":" + ip_field + + ",\"payload\":\"" + body_safe + "\"}" + let ds_resp: String = supabase_insert(sb_url, sb_key, "docuseal_events", row) + println("[docuseal] event=" + event_type + " sub=" + sub_id + " -> " + ds_resp) + } + + let ds_is_completed: Bool = str_eq(event_type, "form.completed") + let ds_is_declined: Bool = str_eq(event_type, "form.declined") + if ds_is_completed || ds_is_declined { + let resend_key: String = state_get("__resend_api_key__") + println("[docuseal] gate event=" + event_type + " key=" + (if str_eq(resend_key, "") { "empty" } else { "set" })) + if !str_eq(resend_key, "") { + let subject: String = if str_eq(event_type, "form.completed") { + "DocuSeal: " + signer_email + " signed (#" + sub_id + ")" + } else { + "DocuSeal: " + signer_email + " declined (#" + sub_id + ")" + } + let html: String = "

" + name_safe + " <" + signer_email + ">

" + + "

Submission #" + sub_id + " - " + event_type + " at " + event_ts + "

" + + "
"
+                    + body_safe + "
" + let html_safe: String = str_replace(str_replace(html, "\\", "\\\\"), "\"", "\\\"") + let subject_safe: String = str_replace(subject, "\"", "\\\"") + let email_body: String = "{\"from\":\"DocuSeal \"," + + "\"to\":[\"will.anderson@neurontechnologies.ai\"]," + + "\"subject\":\"" + subject_safe + "\"," + + "\"html\":\"" + html_safe + "\"}" + let mail_resp: String = http_post_auth("https://api.resend.com/emails", resend_key, email_body) + println("[docuseal] resend " + event_type + " -> " + mail_resp) + } + } + + return "{\"ok\":true}" + } + // ── Share card — POST /api/share ────────────────────────────────────────── + // + // Body: {question, answer, answer_html?, answer_plaintext?} + // - answer is the legacy plaintext field (still required for the + // gallery + DB row). + // - answer_html is optional pre-rendered (marked.js) HTML captured + // from bubble.innerHTML at Share-click time. When present, it + // replaces the escaped-plaintext bubble on the share card so the + // visual matches what the user saw in chat. + // - answer_plaintext, when supplied, takes precedence as the og:desc / + // gallery body. Falls back to `answer`. + // + // The handler sanitizes answer_html (sanitize_share_html: strip + // script/iframe/style/object/embed/form/link/meta/base + on*= attrs + + // javascript: URIs) before storing. if str_eq(path, "/api/share") { if !str_eq(method, "POST") { return "{\"error\":\"POST required\"}" } let question: String = json_get(body, "question") let answer: String = json_get(body, "answer") - if str_eq(question, "") || str_eq(answer, "") { + let answer_html_raw: String = json_get(body, "answer_html") + let answer_plain_in: String = json_get(body, "answer_plaintext") + let answer_plain: String = if str_eq(answer_plain_in, "") { answer } else { answer_plain_in } + if str_eq(question, "") || str_eq(answer_plain, "") { return "{\"error\":\"question and answer required\"}" } let ts: String = int_to_str(unix_timestamp()) let id: String = str_slice(ts, str_len(ts) - 8, str_len(ts)) - let html_share: String = share_card_page(question, answer, id) + let html_share: String = share_card_page(question, answer_plain, answer_html_raw, id) let gcs_bucket: String = env("GCS_SHARE_BUCKET") if !str_eq(gcs_bucket, "") { // GCS — durable across Cloud Run instances and restarts @@ -1040,12 +1211,13 @@ fn handle_request(method: String, path: String, body: String) -> String { // Local dev fallback fs_write(src_dir + "/shares/" + id + ".html", html_share) } - // Write to Supabase share_cards for the gallery + voting + // Write to Supabase share_cards for the gallery + voting. We store + // the plaintext answer (gallery thumbnails read this column). let sb_url: String = state_get("__supabase_project_url__") let sb_key: String = state_get("__supabase_service_key__") if !str_eq(sb_key, "") { let q_safe: String = str_replace(str_replace(question, "\\", "\\\\"), "\"", "\\\"") - let a_safe: String = str_replace(str_replace(str_replace(str_replace(answer, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r") + let a_safe: String = str_replace(str_replace(str_replace(str_replace(answer_plain, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r") let card_row: String = "{\"id\":\"" + id + "\",\"question\":\"" + q_safe + "\",\"answer\":\"" + a_safe + "\"}" let sb_resp: String = supabase_insert(sb_url, sb_key, "share_cards", card_row) println("[share] supabase insert " + id + " -> " + sb_resp) @@ -1053,73 +1225,173 @@ fn handle_request(method: String, path: String, body: String) -> String { return "{\"id\":\"" + id + "\"}" } - // ── Vote on a share card — POST /api/vote ──────────────────────────────── + // ── Vote on a share card — POST /api/vote (auth-gated) ─────────────────── + // + // Body: {access_token: , id: , direction: "up"|"down"|"none"} + // "up" - upsert vote with direction up (idempotent) + // "down" - upsert vote with direction down (or change from up) + // "none" - undo (delete the user's vote for this card) + // + // The access_token is validated against /auth/v1/user; the resulting JWT + // (NOT the service key) is the Authorization header on the share_votes + // write so RLS policy auth.uid()=user_id treats the row as user-owned. + // share_cards.upvotes/downvotes/score are kept in sync by the + // recalc_share_card_score trigger. After the write we re-read the + // aggregate with the service key (public read) so the client gets fresh + // totals + the new user_vote in one round trip. if str_eq(path, "/api/vote") { if !str_eq(method, "POST") { - return "{\"error\":\"POST required\"}" + return "{\"__status__\":405,\"error\":\"POST required\"}" } - let vote_id: String = json_get(body, "id") - let direction: String = json_get(body, "direction") - if str_eq(vote_id, "") || str_eq(direction, "") { - return "{\"error\":\"id and direction required\"}" + let v_jwt: String = json_get(body, "access_token") + let v_id: String = json_get(body, "id") + let v_dir: String = json_get(body, "direction") + if str_eq(v_id, "") || str_eq(v_dir, "") { + return "{\"__status__\":400,\"error\":\"id and direction required\"}" } - let sb_url: String = state_get("__supabase_project_url__") - let sb_key: String = state_get("__supabase_service_key__") - if str_eq(sb_key, "") { - return "{\"error\":\"not configured\"}" + if !str_eq(v_dir, "up") && !str_eq(v_dir, "down") && !str_eq(v_dir, "none") { + return "{\"__status__\":400,\"error\":\"direction must be up, down, or none\"}" } - // Use Supabase RPC to atomically increment the right column - let col: String = if str_eq(direction, "up") { "upvotes" } else { "downvotes" } - let score_delta: String = if str_eq(direction, "up") { "1" } else { "-1" } - // PATCH via Supabase REST — increment upvotes or downvotes + recalculate score - let update_json: String = "{\"" + col + "\":\"" + col + " + 1\",\"score\":\"score + " + score_delta + "\"}" - // Use Postgres RPC for atomic increment - let rpc_body: String = "{\"p_id\":\"" + vote_id + "\",\"p_col\":\"" + col + "\",\"p_delta\":" + score_delta + "}" - // Fallback: direct update via REST with computed columns - let up_resp: String = http_post_auth( - sb_url + "/rest/v1/rpc/vote_card", - sb_key, - rpc_body + if str_eq(v_jwt, "") { + return "{\"__status__\":401,\"error\":\"login_required\"}" + } + let v_sb_url: String = state_get("__supabase_project_url__") + let v_anon: String = state_get("__supabase_anon_key__") + let v_service: String = state_get("__supabase_service_key__") + if str_eq(v_anon, "") || str_eq(v_service, "") { + return "{\"__status__\":500,\"error\":\"not_configured\"}" + } + // Validate the JWT - "" means invalid / expired / revoked. + let v_user: String = supabase_auth_user(v_sb_url, v_anon, v_jwt) + let v_uid: String = json_get(v_user, "id") + if str_eq(v_uid, "") { + return "{\"__status__\":401,\"error\":\"invalid_token\"}" + } + // Direction "none" - undo. DELETE share_votes where (share_id, user_id). + // The user JWT (NOT service key) is the auth so RLS auth.uid()=user_id + // passes. The recalc trigger updates share_cards aggregates. + if str_eq(v_dir, "none") { + let del_url: String = v_sb_url + "/rest/v1/share_votes?share_id=eq." + v_id + "&user_id=eq." + v_uid + let _del_resp: String = http_delete_auth(del_url, v_jwt, v_anon) + } else { + // up/down - upsert. PostgREST resolves on the (share_id, user_id) PK + // when on_conflict matches. The trigger fires on insert and update. + let row: String = "{\"share_id\":\"" + v_id + "\",\"user_id\":\"" + v_uid + "\",\"direction\":\"" + v_dir + "\"}" + let up_path: String = "share_votes?on_conflict=share_id,user_id" + let _up_resp: String = supabase_upsert_user(v_sb_url, v_anon, v_jwt, up_path, row) + } + // Re-fetch fresh aggregate from share_cards (service key - public read). + // PostgREST returns a JSON array; use json_array_get(0) then json_get. + let v_agg: String = supabase_get( + v_sb_url, v_service, + "share_cards?select=score,upvotes,downvotes&id=eq." + v_id ) - println("[vote] " + direction + " on " + vote_id + " -> " + up_resp) - return "{\"ok\":true}" + let v_row: String = json_array_get(v_agg, 0) + let v_score_raw: String = json_get(v_row, "score") + let v_up_raw: String = json_get(v_row, "upvotes") + let v_down_raw: String = json_get(v_row, "downvotes") + let v_score_str: String = if str_eq(v_score_raw, "") { "0" } else { v_score_raw } + let v_up_str: String = if str_eq(v_up_raw, "") { "0" } else { v_up_raw } + let v_down_str: String = if str_eq(v_down_raw, "") { "0" } else { v_down_raw } + let v_user_vote: String = if str_eq(v_dir, "none") { "none" } else { v_dir } + return "{\"ok\":true,\"score\":" + v_score_str + ",\"upvotes\":" + v_up_str + ",\"downvotes\":" + v_down_str + ",\"user_vote\":\"" + v_user_vote + "\"}" } - // ── Vote count — GET /api/vote-count/ ──────────────────────────────── + // ── Vote state — GET /api/vote-state/ (auth-aware) ───────────── + // + // Always returns the public aggregate. If a Supabase access_token is + // supplied via ?access_token=, validate it and include the user's + // current vote as `user_vote` (one of up / down / none). An anonymous + // caller gets `user_vote:"none"`. + if str_starts_with(path, "/api/vote-state/") { + let vs_rest: String = str_slice(path, 16, str_len(path)) + // Strip query string from id if access_token was passed inline + let vs_q_idx: Int = str_index_of(vs_rest, "?") + let vs_id: String = if vs_q_idx >= 0 { str_slice(vs_rest, 0, vs_q_idx) } else { vs_rest } + // Pull access_token out of the query string + let vs_jwt: String = "" + let vs_at_idx: Int = str_index_of(vs_rest, "access_token=") + if vs_at_idx >= 0 { + let vs_tail: String = str_slice(vs_rest, vs_at_idx + 13, str_len(vs_rest)) + let vs_amp_idx: Int = str_index_of(vs_tail, "&") + let vs_jwt = if vs_amp_idx >= 0 { str_slice(vs_tail, 0, vs_amp_idx) } else { vs_tail } + } + let vs_sb_url: String = state_get("__supabase_project_url__") + let vs_anon: String = state_get("__supabase_anon_key__") + let vs_service: String = state_get("__supabase_service_key__") + if str_eq(vs_service, "") { + return "{\"score\":0,\"upvotes\":0,\"downvotes\":0,\"user_vote\":\"none\"}" + } + let vs_resp: String = supabase_get( + vs_sb_url, vs_service, + "share_cards?select=score,upvotes,downvotes&id=eq." + vs_id + ) + let vs_row: String = json_array_get(vs_resp, 0) + let vs_score_raw: String = json_get(vs_row, "score") + let vs_up_raw: String = json_get(vs_row, "upvotes") + let vs_down_raw: String = json_get(vs_row, "downvotes") + let vs_score_str: String = if str_eq(vs_score_raw, "") { "0" } else { vs_score_raw } + let vs_up_str: String = if str_eq(vs_up_raw, "") { "0" } else { vs_up_raw } + let vs_down_str: String = if str_eq(vs_down_raw, "") { "0" } else { vs_down_raw } + let vs_user_vote: String = "none" + if !str_eq(vs_jwt, "") { + let vs_user: String = supabase_auth_user(vs_sb_url, vs_anon, vs_jwt) + let vs_uid: String = json_get(vs_user, "id") + if !str_eq(vs_uid, "") { + let vs_vote_resp: String = supabase_get( + vs_sb_url, vs_service, + "share_votes?select=direction&share_id=eq." + vs_id + "&user_id=eq." + vs_uid + ) + let vs_vote_row: String = json_array_get(vs_vote_resp, 0) + let vs_vote_dir: String = json_get(vs_vote_row, "direction") + let vs_user_vote = if str_eq(vs_vote_dir, "") { "none" } else { vs_vote_dir } + } + } + return "{\"score\":" + vs_score_str + ",\"upvotes\":" + vs_up_str + ",\"downvotes\":" + vs_down_str + ",\"user_vote\":\"" + vs_user_vote + "\"}" + } + + // ── Vote count — GET /api/vote-count/ (legacy alias) ───────────────── + // Kept for any cached HTML still polling the old endpoint shape. Returns + // aggregate only - no user_vote. New clients should use /api/vote-state. if str_starts_with(path, "/api/vote-count/") { let vc_id: String = str_slice(path, 16, str_len(path)) - let sb_url: String = state_get("__supabase_project_url__") - let sb_key: String = state_get("__supabase_service_key__") - if str_eq(sb_key, "") { + let vc_sb_url: String = state_get("__supabase_project_url__") + let vc_sb_key: String = state_get("__supabase_service_key__") + if str_eq(vc_sb_key, "") { return "{\"score\":0,\"upvotes\":0,\"downvotes\":0}" } let vc_resp: String = supabase_get( - sb_url, sb_key, + vc_sb_url, vc_sb_key, "share_cards?select=score,upvotes,downvotes&id=eq." + vc_id ) - let score_raw: String = json_get(vc_resp, "0.score") - let up_raw: String = json_get(vc_resp, "0.upvotes") - let down_raw: String = json_get(vc_resp, "0.downvotes") - let score_str: String = if str_eq(score_raw, "") { "0" } else { score_raw } - let up_str: String = if str_eq(up_raw, "") { "0" } else { up_raw } - let down_str: String = if str_eq(down_raw, "") { "0" } else { down_raw } - return "{\"score\":" + score_str + ",\"upvotes\":" + up_str + ",\"downvotes\":" + down_str + "}" + let vc_row: String = json_array_get(vc_resp, 0) + let vc_score_raw: String = json_get(vc_row, "score") + let vc_up_raw: String = json_get(vc_row, "upvotes") + let vc_down_raw: String = json_get(vc_row, "downvotes") + let vc_score_str: String = if str_eq(vc_score_raw, "") { "0" } else { vc_score_raw } + let vc_up_str: String = if str_eq(vc_up_raw, "") { "0" } else { vc_up_raw } + let vc_down_str: String = if str_eq(vc_down_raw, "") { "0" } else { vc_down_raw } + return "{\"score\":" + vc_score_str + ",\"upvotes\":" + vc_up_str + ",\"downvotes\":" + vc_down_str + "}" } // ── Gallery — GET /said ─────────────────────────────────────────────────── + // + // Renders the gallery server-side and injects the Supabase config so the + // page's vote JS can call supabase.auth.getSession() and POST /api/vote + // with the user's JWT. if str_eq(path, "/said") { let sb_url: String = state_get("__supabase_project_url__") let sb_key: String = state_get("__supabase_service_key__") + let sb_anon: String = state_get("__supabase_anon_key__") let cards_json: String = "" if !str_eq(sb_key, "") { - // Use supabase_get which sends both apikey and Authorization headers let cards_json = supabase_get( sb_url, sb_key, "share_cards?select=id,question,answer,score,upvotes,downvotes,created_at&order=score.desc,created_at.desc&limit=100" ) } - return gallery_page(cards_json) + return gallery_page(cards_json, sb_url, sb_anon) } // ── Share card — GET /share/ ────────────────────────────────────────── diff --git a/src/styles.el b/src/styles.el index 648fec2..3de832c 100644 --- a/src/styles.el +++ b/src/styles.el @@ -1956,7 +1956,27 @@ fn page_close() -> String { - + +
+
+
+
+
Preview
+
This is what you are about to publish
+
+ +
+ +
+ + +
+
+
+ + "