flickpick / index.html
usmar's picture
Upload folder using huggingface_hub
33f66e0 verified
Raw
History Blame Contribute Delete
8.94 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>flickpick — movie recommender</title>
<style>
:root {
--bg: #f4f3ef; --surface: #fcfcfb; --ink: #0b0b0b; --muted: #52514e;
--line: #e3e1da; --accent: #2a78d6; --accent-ink: #ffffff;
--bar: #2a78d6; --chip: #e8f0fb; --chip-ink: #1d5cad;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121211; --surface: #1a1a19; --ink: #f4f3ef; --muted: #c3c2b7;
--line: #33322f; --accent: #3987e5; --accent-ink: #ffffff;
--bar: #3987e5; --chip: #1e2c40; --chip-ink: #9dc2f2;
}
}
* { box-sizing: border-box; margin: 0; }
body {
background: var(--bg); color: var(--ink);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex; justify-content: center; padding: 28px 16px 48px;
}
main { width: 100%; max-width: 680px; }
header { margin-bottom: 18px; }
.wordmark { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; }
.wordmark span { color: var(--accent); }
.tagline { color: var(--muted); margin-top: 2px; }
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 18px; margin-bottom: 16px;
}
.searchwrap { position: relative; }
input[type="search"] {
width: 100%; padding: 10px 12px; font: inherit; color: var(--ink);
background: var(--bg); border: 1px solid var(--line); border-radius: 8px; outline: none;
}
input[type="search"]:focus { border-color: var(--accent); }
.dropdown {
position: absolute; top: 44px; left: 0; right: 0; z-index: 5;
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
max-height: 260px; overflow-y: auto; box-shadow: 0 8px 24px rgba(0,0,0,.12);
}
.dropdown button {
display: block; width: 100%; text-align: left; padding: 8px 12px; font: inherit;
background: none; border: 0; color: var(--ink); cursor: pointer;
}
.dropdown button:hover { background: var(--chip); }
.dropdown .g { color: var(--muted); font-size: 12.5px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 8px; }
.chip {
background: var(--chip); color: var(--chip-ink); border-radius: 999px;
padding: 5px 12px; font-size: 13.5px; font-weight: 600; display: inline-flex;
align-items: center; gap: 7px;
}
.chip button { border: 0; background: none; color: inherit; cursor: pointer; font-size: 15px; padding: 0; line-height: 1; }
.personas { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
.personas .lbl { color: var(--muted); font-size: 13px; margin-right: 2px; }
.personas button {
font: inherit; font-size: 13.5px; padding: 5px 12px; border-radius: 999px;
border: 1px solid var(--line); background: none; color: var(--ink); cursor: pointer;
}
.personas button:hover { border-color: var(--accent); color: var(--accent); }
.go {
margin-top: 16px; width: 100%; padding: 11px; font: inherit; font-weight: 700;
background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 8px;
cursor: pointer;
}
.go:disabled { opacity: .45; cursor: default; }
.results h2 { font-size: 15px; margin-bottom: 4px; }
.results .sub { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
.rec { display: grid; grid-template-columns: 30px 1fr; gap: 4px 10px; padding: 10px 0; border-top: 1px solid var(--line); }
.rec .rank { color: var(--muted); font-weight: 700; font-variant-numeric: tabular-nums; padding-top: 1px; }
.rec .title { font-weight: 650; }
.rec .title .yr { color: var(--muted); font-weight: 400; }
.rec .meta { grid-column: 2; color: var(--muted); font-size: 13px; }
.rec .barwrap { grid-column: 2; height: 5px; background: var(--line); border-radius: 3px; overflow: hidden; margin-top: 3px; }
.rec .bar { height: 100%; background: var(--bar); border-radius: 3px; }
.rec .because { grid-column: 2; color: var(--muted); font-size: 12.5px; font-style: italic; }
footer { color: var(--muted); font-size: 12.5px; text-align: center; margin-top: 10px; }
footer a { color: var(--accent); text-decoration: none; }
.hidden { display: none; }
.spin { color: var(--muted); font-size: 13.5px; padding: 6px 0; }
</style>
</head>
<body>
<main>
<header>
<div class="wordmark">flick<span>pick</span> 🎬</div>
<div class="tagline">Pick a few movies you love — get ten you'll probably like next.</div>
</header>
<div class="card">
<div class="searchwrap">
<input id="q" type="search" placeholder="Search the MovieLens catalog — try &quot;matrix&quot; or &quot;toy story&quot;" autocomplete="off">
<div id="dd" class="dropdown hidden"></div>
</div>
<div id="chips" class="chips"></div>
<div class="personas">
<span class="lbl">or try a taste profile:</span>
<span id="personaBtns"></span>
</div>
<button id="go" class="go" disabled>Recommend me 10 movies</button>
</div>
<div id="results" class="card results hidden">
<h2>Your next ten</h2>
<div class="sub" id="resultsSub"></div>
<div id="recList"></div>
</div>
<footer>
Implicit ALS (hand-rolled, NumPy) · MovieLens&nbsp;1M · model runs entirely in your browser (96×96 solve per request) ·
<a href="https://github.com/UsmarHaider/flickpick">source on GitHub</a>
</footer>
</main>
<script src="model.js"></script>
<script>
const $ = id => document.getElementById(id);
let picks = []; // {movie_id, title, year, genres}
let personas = [];
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
function label(m) { return m.title + (m.year ? ` (${m.year})` : ""); }
function renderChips() {
$("chips").innerHTML = picks.map((m, i) =>
`<span class="chip">${label(m)} <button aria-label="remove" onclick="removePick(${i})">&times;</button></span>`
).join("");
$("go").disabled = picks.length === 0;
}
function removePick(i) { picks.splice(i, 1); renderChips(); }
function addPick(m) {
if (!picks.some(p => p.movie_id === m.movie_id)) picks.push(m);
$("q").value = ""; $("dd").classList.add("hidden");
renderChips();
}
const doSearch = debounce(async q => {
if (q.trim().length < 2) { $("dd").classList.add("hidden"); return; }
const hits = await (await fetch(`search?q=${encodeURIComponent(q)}`)).json();
if (!hits.length) { $("dd").classList.add("hidden"); return; }
$("dd").innerHTML = hits.map(m =>
`<button data-m='${JSON.stringify(m).replace(/'/g, "&#39;")}'>${label(m)} <span class="g">· ${m.genres}</span></button>`
).join("");
$("dd").classList.remove("hidden");
$("dd").querySelectorAll("button").forEach(b =>
b.addEventListener("click", () => addPick(JSON.parse(b.dataset.m))));
}, 200);
$("q").addEventListener("input", e => doSearch(e.target.value));
document.addEventListener("click", e => {
if (!e.target.closest(".searchwrap")) $("dd").classList.add("hidden");
});
async function loadPersonas() {
const btns = [];
for (let i = 0; i < 4; i++) {
const p = await (await fetch(`sample?index=${i}`)).json();
personas.push(p);
btns.push(`<button onclick="usePersona(${i})">${p.name}</button>`);
}
$("personaBtns").innerHTML = btns.join(" ");
}
function usePersona(i) { picks = [...personas[i].movies]; renderChips(); }
async function recommend() {
$("results").classList.remove("hidden");
$("recList").innerHTML = '<div class="spin">Folding you into the model…</div>';
const res = await fetch("recommend", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ movie_ids: picks.map(p => p.movie_id), n: 10 }),
});
const data = await res.json();
$("resultsSub").textContent = `Based on ${data.picks.length} pick${data.picks.length > 1 ? "s" : ""} — ranked by ALS score, bar shows match vs the top pick.`;
$("recList").innerHTML = data.recommendations.map(r => `
<div class="rec">
<div class="rank">${r.rank}</div>
<div class="title">${r.title} <span class="yr">${r.year ? "(" + r.year + ")" : ""}</span></div>
<div class="meta">${r.genres}</div>
<div class="barwrap"><div class="bar" style="width:${Math.max(4, Math.round(r.match * 100))}%"></div></div>
<div class="because">because you liked ${r.because}</div>
</div>`).join("");
if (!document.body.dataset.demo) {
$("results").scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
$("go").addEventListener("click", recommend);
(async function init() {
await loadPersonas();
const params = new URLSearchParams(location.search);
if (params.get("demo") === "1") {
document.body.dataset.demo = "1";
usePersona(parseInt(params.get("sample") || "0", 10) % personas.length);
recommend();
}
})();
</script>
</body>
</html>