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
+
+
×
+
+
+
+ Cancel
+ Publish to gallery
+
+
+
+
+
+"""
+
+# 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-->")
+ let s5: String = str_replace(str_replace(s4, "", "/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
+
+
×
+
+
+
+ Cancel
+ Publish to gallery
+
+
+
+
+