fix(gallery): implement voting JS + fix change-vote server path

Voting was completely broken: gallery.el referenced d8251f5e5aa1.js
which was never written. Buttons rendered disabled with no JS to
enable them, load vote state, or call /api/vote.

Fix 1 — client: inline the voting script directly in gallery.el.
Initializes Supabase client from window.NEURON_CFG, calls
/api/vote-state/<id> on load (with JWT if signed in) to populate
scores and active states, wires vote buttons with toggle logic
(same direction = undo/none), handles sign-in modal with magic-link
flow, re-loads all vote states on auth state change.

Fix 2 — server: replace supabase_upsert_user (upsert via user JWT)
with delete-then-insert. Upsert requires both INSERT + UPDATE RLS
policies; the UPDATE policy is typically absent on share_votes.
Delete (user JWT, RLS-safe) + insert (service key, user already
auth-validated) is reliable for both new votes and vote changes.
This commit is contained in:
Will Anderson
2026-05-03 11:19:09 -05:00
parent 254afd2fb2
commit 102343c8fe
2 changed files with 117 additions and 5 deletions
+107 -1
View File
@@ -271,7 +271,113 @@ window.NEURON_CFG=window.NEURON_CFG||{};
window.NEURON_CFG.supabase_url=\"" + supabase_url + "\"; window.NEURON_CFG.supabase_url=\"" + supabase_url + "\";
window.NEURON_CFG.supabase_anon_key=\"" + supabase_anon_key + "\"; window.NEURON_CFG.supabase_anon_key=\"" + supabase_anon_key + "\";
</script> </script>
<script src=\"/assets/js/d8251f5e5aa1.js\" defer></script> <script>
(function(){
var cfg=window.NEURON_CFG||{};
var sbUrl=cfg.supabase_url,sbKey=cfg.supabase_anon_key;
if(!sbUrl||!sbKey)return;
var sb=window.supabase.createClient(sbUrl,sbKey);
var token=null;
var votes={};
function getCtrl(sid){
var found=null;
document.querySelectorAll('.vote-controls').forEach(function(c){
if(c.getAttribute('data-share-id')===sid)found=c;
});
return found;
}
function applyState(ctrl,state){
var sid=ctrl.getAttribute('data-share-id');
votes[sid]=state.user_vote||'none';
var scoreEl=ctrl.querySelector('.vote-score');
if(scoreEl&&state.score!=null)scoreEl.textContent=state.score;
var upBtn=ctrl.querySelector('.vote-btn.vote-up');
var dnBtn=ctrl.querySelector('.vote-btn.vote-down');
if(upBtn){upBtn.disabled=false;upBtn.classList.toggle('is-active',state.user_vote==='up');}
if(dnBtn){dnBtn.disabled=false;dnBtn.classList.toggle('is-active',state.user_vote==='down');}
}
function loadVoteState(sid){
var url='/api/vote-state/'+sid;
if(token)url+='?access_token='+encodeURIComponent(token);
fetch(url).then(function(r){return r.json();}).then(function(d){
var ctrl=getCtrl(sid);
if(ctrl)applyState(ctrl,d);
}).catch(function(){});
}
function loadAll(){
document.querySelectorAll('.vote-controls').forEach(function(ctrl){
var id=ctrl.getAttribute('data-share-id');
if(id)loadVoteState(id);
});
}
function castVote(sid,direction){
if(!token){showSignIn();return;}
var ctrl=getCtrl(sid);
var btns=ctrl?ctrl.querySelectorAll('.vote-btn'):[];
btns.forEach(function(b){b.disabled=true;b.classList.add('is-loading');});
fetch('/api/vote',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({access_token:token,id:sid,direction:direction})
}).then(function(r){return r.json();}).then(function(d){
if(ctrl&&d.ok)applyState(ctrl,d);
btns.forEach(function(b){b.disabled=false;b.classList.remove('is-loading');});
}).catch(function(){
btns.forEach(function(b){b.disabled=false;b.classList.remove('is-loading');});
});
}
document.querySelectorAll('.vote-controls').forEach(function(ctrl){
ctrl.querySelectorAll('.vote-btn').forEach(function(btn){
btn.addEventListener('click',function(){
var sid=ctrl.getAttribute('data-share-id');
if(!token){showSignIn();return;}
var dir=btn.getAttribute('data-direction');
var cur=votes[sid]||'none';
castVote(sid,cur===dir?'none':dir);
});
});
});
var modal=document.getElementById('signin-modal');
var cancelEl=document.getElementById('signin-cancel');
var sendEl=document.getElementById('signin-send');
var emailEl=document.getElementById('signin-email');
var msgEl=document.getElementById('signin-msg');
function showSignIn(){if(modal)modal.classList.add('open');if(emailEl)emailEl.focus();}
if(cancelEl)cancelEl.addEventListener('click',function(){modal.classList.remove('open');});
if(modal)modal.addEventListener('click',function(e){if(e.target===modal)modal.classList.remove('open');});
if(sendEl)sendEl.addEventListener('click',function(){
var email=emailEl?emailEl.value.trim():'';
if(!email){if(msgEl)msgEl.textContent='Please enter your email.';return;}
sendEl.disabled=true;
if(msgEl)msgEl.textContent='Sending...';
sb.auth.signInWithOtp({email:email,options:{emailRedirectTo:window.location.href}}).then(function(r){
sendEl.disabled=false;
if(msgEl)msgEl.textContent=r.error?(r.error.message||'Error. Try again.'):'Check your email for a sign-in link.';
});
});
if(emailEl)emailEl.addEventListener('keydown',function(e){if(e.key==='Enter'&&sendEl)sendEl.click();});
sb.auth.onAuthStateChange(function(event,session){
token=session?session.access_token:null;
if(token&&modal)modal.classList.remove('open');
loadAll();
});
sb.auth.getSession().then(function(r){
token=r.data&&r.data.session?r.data.session.access_token:null;
loadAll();
});
})();
</script>
<script src=\"/assets/js/cd30551e3c3b.js\" defer></script> <script src=\"/assets/js/cd30551e3c3b.js\" defer></script>
</body> </body>
</html>" </html>"
+10 -4
View File
@@ -1448,11 +1448,17 @@ fn handle_request(method: String, path: String, body: String) -> String {
let del_url: String = v_sb_url + "/rest/v1/share_votes?share_id=eq." + v_id + "&user_id=eq." + v_uid 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) let _del_resp: String = http_delete_auth(del_url, v_jwt, v_anon)
} else { } else {
// up/down - upsert. PostgREST resolves on the (share_id, user_id) PK // up/down delete any existing vote first (idempotent), then insert fresh.
// when on_conflict matches. The trigger fires on insert and update. // Upsert via user JWT requires both INSERT + UPDATE RLS policies; the
// UPDATE policy is often absent. Delete-then-insert is more reliable:
// user JWT covers the DELETE (auth.uid()=user_id RLS passes),
// service key covers the INSERT (bypasses RLS; user identity already
// validated above via supabase_auth_user). The recalc trigger fires on
// both operations and keeps share_cards.score accurate.
let del_url: String = v_sb_url + "/rest/v1/share_votes?share_id=eq." + v_id + "&user_id=eq." + v_uid
let _del_r: String = http_delete_auth(del_url, v_jwt, v_anon)
let row: String = "{\"share_id\":\"" + v_id + "\",\"user_id\":\"" + v_uid + "\",\"direction\":\"" + v_dir + "\"}" 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 _ins_r: String = supabase_insert(v_sb_url, v_service, "share_votes", row)
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). // Re-fetch fresh aggregate from share_cards (service key - public read).
// PostgREST returns a JSON array; use json_array_get(0) then json_get. // PostgREST returns a JSON array; use json_array_get(0) then json_get.