From e1210383827610f8cb724b2a6fd88ffaaf9985e3 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sat, 2 May 2026 12:14:31 -0500 Subject: [PATCH] fix(gallery): proper auth-gated voting with persistence, undo, and change Replaces the broken counter-bump RPC with a per-user share_votes table (PK share_id+user_id, RLS-enforced ownership). One vote per user per card, change direction or undo any time. Auth required for write; read is public. share_cards.upvotes/downvotes/score stay in sync via recalc trigger. New endpoints: POST /api/vote (auth-gated), GET /api/vote-state/:id (auth-aware). --- build-stage.sh | 2 +- migrations/20260502115405_share_votes.sql | 105 ++++++++++++++++++++++ src/gallery.el | 73 ++++++++++++--- 3 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 migrations/20260502115405_share_votes.sql diff --git a/build-stage.sh b/build-stage.sh index e609d03..b23bb44 100755 --- a/build-stage.sh +++ b/build-stage.sh @@ -84,7 +84,7 @@ else SED_INPLACE=(-i '') fi sed "${SED_INPLACE[@]}" \ - 's|#include "el_runtime.h"|#include "el_runtime.h"\nel_val_t http_get_auth(el_val_t url, el_val_t tok);\nel_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t cwd(void);\nel_val_t color_bold(el_val_t s);\nel_val_t unix_timestamp(void);\nel_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content);\nel_val_t gcs_read(el_val_t bucket, el_val_t object_name);\nel_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json);\nel_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query);\nel_val_t supabase_auth_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt);|' \ + 's|#include "el_runtime.h"|#include "el_runtime.h"\nel_val_t http_get_auth(el_val_t url, el_val_t tok);\nel_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t http_delete_auth(el_val_t url, el_val_t bearer_tok, el_val_t apikey);\nel_val_t cwd(void);\nel_val_t color_bold(el_val_t s);\nel_val_t unix_timestamp(void);\nel_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content);\nel_val_t gcs_read(el_val_t bucket, el_val_t object_name);\nel_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json);\nel_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query);\nel_val_t supabase_auth_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt);\nel_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_val_t body_json);\nel_val_t supabase_upsert_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt, el_val_t table_and_query, el_val_t row_json);|' \ dist/main.c echo "==> Building Docker image marketing:${TAG} for linux/amd64" diff --git a/migrations/20260502115405_share_votes.sql b/migrations/20260502115405_share_votes.sql new file mode 100644 index 0000000..95cf003 --- /dev/null +++ b/migrations/20260502115405_share_votes.sql @@ -0,0 +1,105 @@ +-- 20260502115405_share_votes.sql +-- +-- Per-user vote tracking for the /said share gallery. +-- +-- Replaces the broken counter-bump RPC (vote_card) with a proper relational +-- design: one row per (share_card, user) pair, RLS-enforced ownership, +-- aggregate columns on share_cards kept in sync via trigger. Voters can +-- change direction or undo at any time. One vote per user per card. +-- +-- This migration is idempotent: re-running it is a no-op. + +-- --------------------------------------------------------------------------- +-- Table +-- --------------------------------------------------------------------------- + +create table if not exists public.share_votes ( + share_id text not null references public.share_cards(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + direction text not null check (direction in ('up','down')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (share_id, user_id) +); + +create index if not exists share_votes_share_id_idx on public.share_votes (share_id); +create index if not exists share_votes_user_id_idx on public.share_votes (user_id); + +-- --------------------------------------------------------------------------- +-- Row Level Security +-- --------------------------------------------------------------------------- + +alter table public.share_votes enable row level security; + +drop policy if exists "users can read all votes" on public.share_votes; +drop policy if exists "users can insert their own votes" on public.share_votes; +drop policy if exists "users can update their own votes" on public.share_votes; +drop policy if exists "users can delete their own votes" on public.share_votes; + +create policy "users can read all votes" on public.share_votes + for select using (true); + +create policy "users can insert their own votes" on public.share_votes + for insert with check (auth.uid() = user_id); + +create policy "users can update their own votes" on public.share_votes + for update using (auth.uid() = user_id); + +create policy "users can delete their own votes" on public.share_votes + for delete using (auth.uid() = user_id); + +-- --------------------------------------------------------------------------- +-- Aggregate sync trigger +-- +-- After every insert / update / delete on share_votes, recompute the three +-- aggregate columns on the parent share_cards row. SECURITY DEFINER is +-- required because the calling user only has RLS access to their own vote +-- but the trigger needs to update share_cards.upvotes/downvotes/score. +-- --------------------------------------------------------------------------- + +create or replace function public.recalc_share_card_score() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + s_id text; +begin + s_id := coalesce(NEW.share_id, OLD.share_id); + update public.share_cards + set upvotes = (select count(*) from public.share_votes + where share_id = s_id and direction = 'up'), + downvotes = (select count(*) from public.share_votes + where share_id = s_id and direction = 'down'), + score = (select count(*) filter (where direction = 'up') + - count(*) filter (where direction = 'down') + from public.share_votes where share_id = s_id) + where id = s_id; + return null; +end; +$$; + +drop trigger if exists share_votes_recalc on public.share_votes; +create trigger share_votes_recalc + after insert or update or delete on public.share_votes + for each row execute function public.recalc_share_card_score(); + +-- --------------------------------------------------------------------------- +-- updated_at touch trigger (only on UPDATE) +-- --------------------------------------------------------------------------- + +create or replace function public.touch_share_votes_updated_at() +returns trigger +language plpgsql +as $$ +begin + NEW.updated_at := now(); + return NEW; +end; +$$; + +drop trigger if exists share_votes_touch on public.share_votes; +create trigger share_votes_touch + before update on public.share_votes + for each row execute function public.touch_share_votes_updated_at(); diff --git a/src/gallery.el b/src/gallery.el index ae0dbc8..0ae14b7 100644 --- a/src/gallery.el +++ b/src/gallery.el @@ -1,7 +1,7 @@ // components/gallery.el - "Things Neuron Said" gallery page. -// Uses the full site nav and styles — consistent with the rest of the site. +// Per-card auth-gated voting via supabase-js + /api/vote. -fn gallery_page(cards_json: String) -> String { +fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: String) -> String { let i: Int = 0 let cards_html: String = "" let n: Int = json_array_len(cards_json) @@ -16,16 +16,20 @@ fn gallery_page(cards_json: String) -> String { let a_html: String = str_replace(str_replace(str_replace(ca, "&", "&"), "<", "<"), ">", ">") let ts_raw: String = json_get(card, "created_at") let ts_short: String = str_slice(ts_raw, 0, 10) - let votes_label: String = if str_eq(score, "1") { "1 vote" } else { score + " votes" } - let card_open: String = "" - let card_html: String = card_open + " -
" + q_html + "
-
" + a_html + "
+ let card_html: String = "
+ +
" + q_html + "
+
" + a_html + "
+
- " + votes_label + " - " + ts_short + " · Read ↗ +
+ + " + score + " + +
+ " + ts_short + " · Read ↗
-" +
" let cards_html = cards_html + card_html let i = i + 1 } @@ -51,6 +55,7 @@ fn gallery_page(cards_json: String) -> String { --navy: #0052A0; --navy-65: rgba(0,82,160,.65); --navy-85: rgba(0,82,160,.85); --t1: #0D0D14; --t2: #3A3A4A; --t3: #6B6B7E; --border: rgba(0,0,0,.07); --border2: rgba(0,0,0,.13); + --up: #2E7D32; --down: #C62828; --head: 'Playfair Display', Georgia, serif; --body: 'IBM Plex Sans', system-ui, sans-serif; } @@ -104,11 +109,23 @@ body::before { .gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; } @media (max-width: 900px) { .gallery-grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 600px) { .gallery-grid { grid-template-columns: 1fr; } } -.gal-card { background: var(--card); border: 1px solid var(--border); padding: 1.5rem; display: flex; flex-direction: column; gap: .875rem; text-decoration: none; transition: border-color .15s, box-shadow .15s; } +.gal-card { background: var(--card); border: 1px solid var(--border); padding: 1.5rem; display: flex; flex-direction: column; gap: .875rem; transition: border-color .15s, box-shadow .15s; } .gal-card:hover { border-color: rgba(0,82,160,.25); box-shadow: 0 4px 20px rgba(0,82,160,.07); } +.gal-link { text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: .875rem; flex: 1; } .gal-q { font-family: var(--body); font-weight: 500; font-size: .875rem; color: var(--navy); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .gal-a { font-family: var(--body); font-weight: 300; font-size: .8125rem; color: var(--t2); line-height: 1.7; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; flex: 1; } .gal-meta { display: flex; justify-content: space-between; align-items: center; font-family: var(--body); font-size: .65rem; font-weight: 400; letter-spacing: .1em; text-transform: uppercase; color: var(--t3); margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } +.gal-date { font-size: .65rem; color: var(--t3); } +/* Vote controls */ +.vote-controls { display: inline-flex; align-items: center; gap: .35rem; } +.vote-btn { background: transparent; border: 1px solid var(--border2); color: var(--t3); cursor: pointer; padding: .2rem .45rem; font-size: .7rem; line-height: 1; transition: all .15s; -webkit-tap-highlight-color: transparent; } +.vote-btn:hover:not(:disabled) { color: var(--navy); border-color: rgba(0,82,160,.35); background: rgba(0,82,160,.04); } +.vote-btn:focus-visible { outline: 2px solid var(--navy); outline-offset: 1px; } +.vote-btn:disabled { opacity: .55; cursor: not-allowed; } +.vote-btn.is-active.vote-up { background: rgba(46,125,50,.10); color: var(--up); border-color: rgba(46,125,50,.45); } +.vote-btn.is-active.vote-down { background: rgba(198,40,40,.10); color: var(--down); border-color: rgba(198,40,40,.45); } +.vote-btn.is-loading { opacity: .55; cursor: wait; } +.vote-score { font-size: .8rem; font-weight: 500; color: var(--t1); min-width: 1.5rem; text-align: center; font-variant-numeric: tabular-nums; } .gallery-controls { display: flex; align-items: center; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; } .gallery-search { flex: 1; min-width: 200px; font-family: var(--body); font-size: .875rem; font-weight: 300; color: var(--t1); background: #fff; border: 1px solid var(--border2); padding: .625rem 1rem; outline: none; transition: border-color .2s; } .gallery-search:focus { border-color: var(--navy); } @@ -117,6 +134,19 @@ body::before { .sort-btn:hover, .sort-btn.active { color: var(--navy); border-color: rgba(0,82,160,.3); background: rgba(0,82,160,.03); } .gallery-empty { grid-column: 1/-1; padding: 4rem 0; text-align: center; } .gal-card.hidden { display: none; } +/* Sign-in modal */ +.signin-modal { position: fixed; inset: 0; background: rgba(13,13,20,.55); display: none; align-items: center; justify-content: center; z-index: 1000; padding: 1rem; } +.signin-modal.open { display: flex; } +.signin-modal-card { background: #fff; border: 1px solid var(--border2); padding: 2rem; max-width: 22rem; width: 100%; box-shadow: 0 16px 48px rgba(0,0,0,.18); } +.signin-modal h2 { font-family: var(--head); font-size: 1.4rem; font-weight: 600; color: var(--t1); margin-bottom: .5rem; } +.signin-modal p { font-family: var(--body); font-size: .85rem; font-weight: 300; color: var(--t2); line-height: 1.6; margin-bottom: 1.25rem; } +.signin-modal input { width: 100%; box-sizing: border-box; font-family: var(--body); font-size: .875rem; font-weight: 300; color: var(--t1); background: #fff; border: 1px solid var(--border2); padding: .75rem 1rem; outline: none; transition: border-color .2s; margin-bottom: .75rem; } +.signin-modal input:focus { border-color: var(--navy); } +.signin-modal-actions { display: flex; gap: .5rem; justify-content: space-between; align-items: center; } +.signin-modal-msg { font-size: .8rem; color: var(--t2); margin-top: .75rem; min-height: 1rem; } +.signin-modal .btn-primary { padding: .7rem 1.25rem; font-size: .7rem; } +.signin-modal-cancel { background: none; border: none; color: var(--t3); cursor: pointer; font-family: var(--body); font-size: .75rem; letter-spacing: .14em; text-transform: uppercase; padding: .7rem .5rem; } +.signin-modal-cancel:hover { color: var(--t1); } @@ -185,6 +215,27 @@ body::before { + +
+
+

Sign in to vote

+

We will email you a sign-in link. No password needed.

+ +
+ + +
+

+
+
+ + + + "