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).
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
+62
-11
@@ -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 = "<a href=\"/share/" + cid + "\" class=\"gal-card\" data-score=\"" + score + "\" data-ts=\"" + cid + "\">"
|
||||
let card_html: String = card_open + "
|
||||
<div class=\"gal-q\">" + q_html + "</div>
|
||||
<div class=\"gal-a\">" + a_html + "</div>
|
||||
let card_html: String = "<div class=\"gal-card\" data-share-id=\"" + cid + "\" data-score=\"" + score + "\" data-ts=\"" + cid + "\">
|
||||
<a href=\"/share/" + cid + "\" class=\"gal-link\">
|
||||
<div class=\"gal-q\">" + q_html + "</div>
|
||||
<div class=\"gal-a\">" + a_html + "</div>
|
||||
</a>
|
||||
<div class=\"gal-meta\">
|
||||
<span>" + votes_label + "</span>
|
||||
<span>" + ts_short + " · Read ↗</span>
|
||||
<div class=\"vote-controls\" data-share-id=\"" + cid + "\">
|
||||
<button type=\"button\" class=\"vote-btn vote-up\" aria-label=\"Upvote\" data-direction=\"up\" disabled>▲</button>
|
||||
<span class=\"vote-score\" data-score=\"" + score + "\">" + score + "</span>
|
||||
<button type=\"button\" class=\"vote-btn vote-down\" aria-label=\"Downvote\" data-direction=\"down\" disabled>▼</button>
|
||||
</div>
|
||||
<span class=\"gal-date\">" + ts_short + " · Read ↗</span>
|
||||
</div>
|
||||
</a>"
|
||||
</div>"
|
||||
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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -185,6 +215,27 @@ body::before {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Magic-link sign-in modal -->
|
||||
<div class=\"signin-modal\" id=\"signin-modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"signin-title\">
|
||||
<div class=\"signin-modal-card\">
|
||||
<h2 id=\"signin-title\">Sign in to vote</h2>
|
||||
<p>We will email you a sign-in link. No password needed.</p>
|
||||
<input type=\"email\" id=\"signin-email\" placeholder=\"your@email.com\" autocomplete=\"email\">
|
||||
<div class=\"signin-modal-actions\">
|
||||
<button type=\"button\" class=\"signin-modal-cancel\" id=\"signin-cancel\">Cancel</button>
|
||||
<button type=\"button\" class=\"btn-primary\" id=\"signin-send\">Send link</button>
|
||||
</div>
|
||||
<p class=\"signin-modal-msg\" id=\"signin-msg\"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src=\"https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.min.js\"></script>
|
||||
<script>
|
||||
window.NEURON_CFG=window.NEURON_CFG||{};
|
||||
window.NEURON_CFG.supabase_url=\"" + supabase_url + "\";
|
||||
window.NEURON_CFG.supabase_anon_key=\"" + supabase_anon_key + "\";
|
||||
</script>
|
||||
<script src=\"/assets/js/d8251f5e5aa1.js\" defer></script>
|
||||
<script src=\"/assets/js/cd30551e3c3b.js\" defer></script>
|
||||
</body>
|
||||
</html>"
|
||||
|
||||
Reference in New Issue
Block a user