/* ========================================================================== M GALA — shared Store API, backed by a real Supabase database. Used by both /public and /admin. Pure vanilla JS, no build step. State is fetched from Supabase on load into an in-memory cache (`state`). Reads are synchronous against that cache (wait for Store.ready first). Writes update the cache immediately for instant UI feedback, then sync to Supabase in the background — except Store.vote(), which is a real round trip (it has to be, so the daily-vote-limit is enforced by the server and shared across every visitor, not just one browser). ========================================================================== */ (function (global) { "use strict"; var SUPABASE_URL = "https://jmvbrlywdvtcwjklxnyv.supabase.co"; var SUPABASE_ANON_KEY = "sb_publishable_viNQ1s-dGdM2lRQ5ptXjxg_3STuPQE2"; if (!global.supabase || !global.supabase.createClient) { console.error("M Gala: Supabase client library did not load (check vendor/supabase.js is included before data.js)."); } var sb = global.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY); /* ---------------------- silhouette placeholder avatars ---------------------- */ var SILHOUETTE_PHOTO = { "bob": "https://d2ol7oe51mr4n9.cloudfront.net/user_3F0UQPoSzrfjet6GK2GDhSGKcei/feda7883-4b62-484b-800f-5decc1a277d0.png", "middle-part": "https://d2ol7oe51mr4n9.cloudfront.net/user_3F0UQPoSzrfjet6GK2GDhSGKcei/ae32b444-b4ce-4de7-b447-b83d5b8bafcb.png", "ponytail": "https://d2ol7oe51mr4n9.cloudfront.net/user_3F0UQPoSzrfjet6GK2GDhSGKcei/83228fd1-1297-4cdd-bdf4-c761a48a1b04.png", "curly": "https://d2ol7oe51mr4n9.cloudfront.net/user_3F0UQPoSzrfjet6GK2GDhSGKcei/2c4db67d-423c-4cd2-a96e-4b472920ee80.png" }; var SILHOUETTE_STYLES = ["bob", "middle-part", "ponytail", "curly"]; function silhouetteDataUri(styleKey) { return SILHOUETTE_PHOTO[styleKey] || SILHOUETTE_PHOTO.bob; } /* -------------------------------- helpers ------------------------------- */ function timeAgo(hours) { if (hours < 1) return "just now"; if (hours < 24) return Math.round(hours) + "h ago"; var d = Math.round(hours / 24); return d + "d ago"; } function fmt(n) { return (n || 0).toLocaleString("en-US"); } function slugifyName(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-+|-+$)/g, ""); } function hoursSince(iso) { if (!iso) return 0; var t = Date.now() - new Date(iso).getTime(); return Math.max(0, t / 3600000); } function todayStr() { var d = new Date(); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); } function voterKey() { try { var k = global.localStorage.getItem("mgala_voter_key"); if (!k) { k = "v" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10); global.localStorage.setItem("mgala_voter_key", k); } return k; } catch (e) { return "anon-" + Math.random().toString(36).slice(2, 10); } } /* --------------------------- row <-> object mapping ---------------------------- */ function rowToNominee(r) { return { id: r.id, name: r.name, category: r.category, city: r.city || "", company: r.company || "", votes: r.votes || 0, addedHoursAgo: hoursSince(r.added_at), photo: r.photo || silhouetteDataUri(SILHOUETTE_STYLES[0]), photoStyle: r.photo_style || null, social: { platform: r.social_platform || "", handle: r.social_handle || "" }, bio: r.bio || "", shares: r.shares || 0, archived: !!r.archived }; } function nomineeToRow(n) { return { id: n.id, name: n.name, category: n.category, city: n.city, company: n.company, votes: n.votes, photo: n.photo, photo_style: n.photoStyle, social_platform: n.social && n.social.platform, social_handle: n.social && n.social.handle, bio: n.bio, shares: n.shares, archived: n.archived }; } function rowToPending(r) { return { id: r.id, nomineeName: r.nominee_name, category: r.category, company: r.company || "", city: r.city || "", socialPlatform: r.social_platform || "", socialHandle: r.social_handle || "", nominatorName: r.nominator_name || "", nominatorPhone: r.nominator_phone || "", nominatorEmail: r.nominator_email || "", status: r.status, photo: r.photo, photoStyle: r.photo_style, submittedHoursAgo: hoursSince(r.created_at) }; } function pendingToRow(p) { return { id: p.id, nominee_name: p.nomineeName, category: p.category, company: p.company, city: p.city, social_platform: p.socialPlatform, social_handle: p.socialHandle, nominator_name: p.nominatorName, nominator_phone: p.nominatorPhone, nominator_email: p.nominatorEmail || null, status: p.status, photo: p.photo, photo_style: p.photoStyle }; } /* -------------------------------- state --------------------------------- */ var state = { categories: [], nominees: [], pending: [], featuredId: null, votedMap: {} }; function bg(promiseLike, label) { Promise.resolve(promiseLike).then(function (res) { if (res && res.error) { console.warn("M Gala: background sync failed (" + label + ")", res.error); notifySyncIssue(); } }).catch(function (err) { console.warn("M Gala: background sync failed (" + label + ")", err); notifySyncIssue(); }); } var lastNotify = 0; function notifySyncIssue() { try { if (Date.now() - lastNotify < 4000) return; lastNotify = Date.now(); if (global.MGala && global.MGala.toast) { global.MGala.toast("Couldn't save that to the server — check your connection and try again.", "fa-triangle-exclamation"); } } catch (e) {} } function fetchAll() { var today = todayStr(); return Promise.all([ sb.from("categories").select("*").order("sort_order", { ascending: true }), sb.from("nominees").select("*"), sb.from("pending").select("*").order("created_at", { ascending: false }), sb.from("featured").select("*").eq("id", 1).maybeSingle(), sb.from("votes_log").select("nominee_id").eq("voter_key", voterKey()).eq("voted_on", today) ]).then(function (results) { var catsRes = results[0], nomRes = results[1], pendRes = results[2], featRes = results[3], votesRes = results[4]; if (catsRes.error) throw catsRes.error; if (nomRes.error) throw nomRes.error; if (pendRes.error) throw pendRes.error; state.categories = (catsRes.data || []).map(function (r) { return { slug: r.slug, name: r.name, icon: r.icon }; }); state.nominees = (nomRes.data || []).map(rowToNominee); state.pending = (pendRes.data || []).map(rowToPending); state.featuredId = (featRes.data && featRes.data.nominee_id) || null; state.votedMap = {}; (votesRes.data || []).forEach(function (v) { state.votedMap[v.nominee_id] = today; }); return state; }).catch(function (err) { console.error("M Gala: failed to load data from Supabase.", err); if (global.MGala && global.MGala.toast) { global.MGala.toast("Couldn't reach the M Gala database — check your connection and reload.", "fa-triangle-exclamation"); } throw err; }); } /* --------------------------------- Store -------------------------------- */ var Store = { ready: fetchAll(), refresh: function () { return fetchAll(); }, SILHOUETTE_STYLES: SILHOUETTE_STYLES, silhouetteUri: silhouetteDataUri, getCategories: function () { return state.categories.slice(); }, getCategory: function (slug) { return state.categories.find(function (c) { return c.slug === slug; }); }, addCategory: function (name) { var slug = slugifyName(name) || ("category-" + Date.now()); state.categories.push({ slug: slug, name: name, icon: "fa-star" }); bg(sb.from("categories").insert({ slug: slug, name: name, icon: "fa-star", sort_order: state.categories.length }), "addCategory"); }, renameCategory: function (slug, name) { var c = Store.getCategory(slug); if (c) { c.name = name; bg(sb.from("categories").update({ name: name }).eq("slug", slug), "renameCategory"); } }, removeCategory: function (slug) { state.categories = state.categories.filter(function (c) { return c.slug !== slug; }); bg(sb.from("categories").delete().eq("slug", slug), "removeCategory"); }, getNominees: function (opts) { opts = opts || {}; var list = state.nominees.filter(function (n) { return opts.includeArchived || !n.archived; }); if (opts.category) list = list.filter(function (n) { return n.category === opts.category; }); if (opts.search) { var q = opts.search.toLowerCase(); list = list.filter(function (n) { return n.name.toLowerCase().indexOf(q) !== -1 || n.company.toLowerCase().indexOf(q) !== -1; }); } return list.slice(); }, getNominee: function (id) { return state.nominees.find(function (n) { return n.id === id; }); }, getTopNominees: function (limit) { return Store.getNominees().sort(function (a, b) { return b.votes - a.votes; }).slice(0, limit || 5); }, getRecentNominees: function (limit) { return Store.getNominees().sort(function (a, b) { return a.addedHoursAgo - b.addedHoursAgo; }).slice(0, limit || 5); }, setArchived: function (id, archived) { var n = Store.getNominee(id); if (n) { n.archived = archived; bg(sb.from("nominees").update({ archived: archived }).eq("id", id), "setArchived"); } }, updateNominee: function (id, patch) { var n = Store.getNominee(id); if (!n) return; Object.assign(n, patch); var rowPatch = {}; if ("name" in patch) rowPatch.name = patch.name; if ("category" in patch) rowPatch.category = patch.category; if ("company" in patch) rowPatch.company = patch.company; if ("city" in patch) rowPatch.city = patch.city; if ("photo" in patch) rowPatch.photo = patch.photo; if ("photoStyle" in patch) rowPatch.photo_style = patch.photoStyle; if ("bio" in patch) rowPatch.bio = patch.bio; bg(sb.from("nominees").update(rowPatch).eq("id", id), "updateNominee"); }, getFeatured: function () { return Store.getNominee(state.featuredId); }, setFeatured: function (id) { state.featuredId = id; bg(sb.from("featured").upsert({ id: 1, nominee_id: id }), "setFeatured"); }, getPending: function (status) { var list = state.pending.slice(); if (status && status !== "all") list = list.filter(function (p) { return p.status === status; }); return list.sort(function (a, b) { return a.submittedHoursAgo - b.submittedHoursAgo; }); }, getPendingCounts: function () { var c = { new: 0, under_review: 0, approved: 0, rejected: 0 }; state.pending.forEach(function (p) { c[p.status] = (c[p.status] || 0) + 1; }); return c; }, getPendingItem: function (id) { return state.pending.find(function (p) { return p.id === id; }); }, updatePending: function (id, patch) { var p = Store.getPendingItem(id); if (!p) return; Object.assign(p, patch); var rowPatch = {}; if ("nomineeName" in patch) rowPatch.nominee_name = patch.nomineeName; if ("category" in patch) rowPatch.category = patch.category; if ("company" in patch) rowPatch.company = patch.company; if ("socialHandle" in patch) rowPatch.social_handle = patch.socialHandle; if ("photo" in patch) rowPatch.photo = patch.photo; if ("photoStyle" in patch) rowPatch.photo_style = patch.photoStyle; if ("status" in patch) rowPatch.status = patch.status; bg(sb.from("pending").update(rowPatch).eq("id", id), "updatePending"); }, approvePending: function (id) { var p = Store.getPendingItem(id); if (!p) return null; p.status = "approved"; var newId = "n-" + id + "-" + Date.now().toString(36); var style = SILHOUETTE_STYLES[state.nominees.length % SILHOUETTE_STYLES.length]; var newNominee = { id: newId, name: p.nomineeName, category: p.category, city: p.city || "Houston, TX", company: p.company || "—", votes: 0, addedHoursAgo: 0, photo: p.photo || silhouetteDataUri(style), photoStyle: p.photoStyle || null, social: { platform: p.socialPlatform, handle: p.socialHandle }, bio: p.nomineeName + " was recently nominated by her community for her outstanding leadership and impact in " + (Store.getCategory(p.category) ? Store.getCategory(p.category).name : "her field") + ".", shares: 0, archived: false }; state.nominees.unshift(newNominee); bg(sb.from("pending").update({ status: "approved" }).eq("id", id), "approvePending:pending"); bg(sb.from("nominees").insert(nomineeToRow(newNominee)), "approvePending:nominee"); return newNominee; }, rejectPending: function (id) { Store.updatePending(id, { status: "rejected" }); }, addNomination: function (data) { var id = "p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); var style = SILHOUETTE_STYLES[state.pending.length % SILHOUETTE_STYLES.length]; var item = Object.assign({ id: id, status: "new", submittedHoursAgo: 0, photo: silhouetteDataUri(style), photoStyle: style }, data); state.pending.unshift(item); bg(sb.from("pending").insert(pendingToRow(item)), "addNomination"); return id; }, hasVoted: function (id) { return !!state.votedMap[id]; }, nextVoteAt: function (id) { return state.votedMap[id] || null; }, vote: function (id) { if (Store.hasVoted(id)) return Promise.resolve(false); return sb.rpc("cast_vote", { p_nominee_id: id, p_voter_key: voterKey() }).then(function (res) { if (res.error) { console.warn("M Gala: vote failed", res.error); notifySyncIssue(); return false; } if (res.data === true) { var n = Store.getNominee(id); if (n) n.votes += 1; state.votedMap[id] = todayStr(); return true; } return false; }).catch(function (err) { console.warn("M Gala: vote failed", err); notifySyncIssue(); return false; }); }, stats: function () { var nominees = Store.getNominees(); return { communityVotes: nominees.reduce(function (s, n) { return s + n.votes; }, 0), publishedNominees: nominees.length, categories: state.categories.length, pendingNew: Store.getPendingCounts().new }; }, timeAgo: timeAgo, fmt: fmt }; global.MGala = { Store: Store }; })(window);