roulette / js /app.js
ratandeep's picture
Deploy Build Small Roulette — static space serendipity engine for the org
653b61d
Raw
History Blame Contribute Delete
5.83 kB
import { CONFIG } from "./config.js";
import { Store } from "./store.js";
import { Bag } from "./bag.js";
import { TRACKS } from "./tracks.js";
import { fetchAllSpacesWithRetry } from "./api.js";
import { mountCard } from "./embed.js";
import * as ui from "./ui.js";
import { initOAuth, signIn, signOut, likeOnHf } from "./oauth.js";
const $ = (id) => document.getElementById(id);
const state = {
store: new Store({ key: CONFIG.storageKey }),
all: [], // all view models
byId: new Map(),
bag: null,
filterTrack: null, // null | "Backyard" | "Wood"
current: null, // current vm
controller: null, // mountCard controller
oauth: null,
};
function idsForTrack(track) {
const list = track ? state.all.filter((v) => v.track === track) : state.all;
return list.map((v) => v.id);
}
function eligibleIds() { return idsForTrack(state.filterTrack); }
function prefetchNext() {
if (!CONFIG.prefetchNext || !state.bag) return;
const peekId = state.bag.bag[state.bag.cursor];
const vm = peekId && state.byId.get(peekId);
if (!vm) return;
let link = document.getElementById("prefetch-link");
if (!link) {
link = document.createElement("link");
link.id = "prefetch-link";
link.rel = "preconnect";
document.head.append(link);
}
link.href = vm.embedUrl;
}
function show(vm) {
if (!vm) return;
state.controller?.destroy();
state.current = vm;
state.store.markSeen(vm.id);
ui.renderCard(vm, state.store);
ui.setProgress(state.bag.progress());
state.controller = mountCard($("stage"), vm, { onSkip: next });
if (CONFIG.resumeRound) state.store.saveRound({ ...state.bag.serialize(), track: state.filterTrack });
prefetchNext();
}
function next() { show(state.byId.get(state.bag.next())); }
function prev() { const id = state.bag.prev(); if (id) show(state.byId.get(id)); }
function cycleTrack() {
const order = [null, ...TRACKS];
const nextTrack = order[(order.indexOf(state.filterTrack) + 1) % order.length];
const ids = idsForTrack(nextTrack);
if (!ids.length) { ui.toast(`No spaces in ${nextTrack}`); return; }
state.filterTrack = nextTrack;
ui.setTrackFilterLabel(state.filterTrack);
state.bag.setIds(ids);
next();
}
function wireKeys() {
document.addEventListener("keydown", (e) => {
const tag = e.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
switch (e.key) {
case " ": case "ArrowRight": e.preventDefault(); next(); break;
case "ArrowLeft": prev(); break;
case "l": case "L": $("btn-like").click(); break;
case "s": case "S": $("btn-save").click(); break;
case "o": case "O": if (state.current) window.open(state.current.hfUrl, "_blank"); break;
case "?": ui.showShortcuts(); break;
}
});
}
function wireButtons() {
$("btn-next").onclick = next;
$("btn-prev").onclick = prev;
$("btn-track").onclick = cycleTrack;
ui.setTrackFilterLabel(null);
$("btn-save").onclick = () => {
if (!state.current) return;
const on = state.store.toggleSaved(state.current.id);
ui.setSaveState(on);
ui.setSavedCount(state.store.savedIds().length);
ui.toast(on ? "Saved 💾" : "Removed from saved");
};
$("btn-saved").onclick = () => {
const vms = state.store.savedIds().map((id) => state.byId.get(id)).filter(Boolean);
ui.openDrawer(vms, {
onOpenHere: (id) => { $("drawer").hidden = true; show(state.byId.get(id)); },
onUnsave: (id) => { state.store.toggleSaved(id); ui.setSavedCount(state.store.savedIds().length); },
});
};
ui.setSavedCount(state.store.savedIds().length);
$("btn-like").onclick = async () => {
const vm = state.current;
if (!vm) return;
const turningOn = !state.store.isLiked(vm.id);
ui.setLikeState(turningOn); // optimistic
const where = await likeOnHf(vm.id, turningOn, state.oauth?.accessToken);
if (where === "hf") {
state.store.setHfLiked(vm.id, turningOn);
vm.likes = Math.max(0, (vm.likes || 0) + (turningOn ? 1 : -1));
ui.setHfLikes(vm.likes);
ui.toast(turningOn ? "Liked on Hugging Face 💚" : "Unliked");
} else {
state.store.setLocalLiked(vm.id, turningOn);
ui.toast(state.oauth
? "Couldn't like on HF — saved locally. Open on HF to like officially ↗"
: "Saved your like locally — sign in to like on HF ↗");
}
};
}
function renderAuth() {
ui.renderAuth(state.oauth?.userInfo, {
onSignIn: signIn,
onSignOut: () => { signOut(); state.oauth = null; renderAuth(); },
});
}
function showError(msg) {
$("stage").innerHTML = `<div class="fallback"><strong>${msg}</strong>
<div class="fallback-actions">
<a class="btn" target="_blank" rel="noopener" href="${CONFIG.apiBase}/${CONFIG.org}">Visit the org ↗</a>
<button class="btn btn-ghost" onclick="location.reload()">Retry</button>
</div></div>`;
}
async function boot() {
wireKeys();
wireButtons();
try {
state.all = await fetchAllSpacesWithRetry();
} catch {
showError("Couldn't reach the Hugging Face API. Check your connection and retry.");
return;
}
if (!state.all.length) { showError("No projects found on the trail yet."); return; }
state.byId = new Map(state.all.map((v) => [v.id, v]));
state.oauth = await initOAuth();
renderAuth();
const saved = CONFIG.resumeRound ? state.store.loadRound() : null;
state.filterTrack = saved?.track ?? null;
ui.setTrackFilterLabel(state.filterTrack);
const canResume = saved && saved.track === state.filterTrack;
state.bag = new Bag(eligibleIds(), { state: canResume ? saved : null });
next();
if (!localStorage.getItem("bsr:hinted")) {
ui.toast("Spot a project · 👍 like · 💾 save · 🧭 Next (or hit Space). Press ? for keys.", 6000);
localStorage.setItem("bsr:hinted", "1");
}
}
boot();