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:
+107
-1
@@ -271,7 +271,113 @@ 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>
|
||||
(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>
|
||||
</body>
|
||||
</html>"
|
||||
|
||||
+10
-4
@@ -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_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.
|
||||
// up/down — delete any existing vote first (idempotent), then insert fresh.
|
||||
// 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 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)
|
||||
let _ins_r: String = supabase_insert(v_sb_url, v_service, "share_votes", 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.
|
||||
|
||||
Reference in New Issue
Block a user