| <!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 "matrix" or "toy story"" 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 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 = []; |
| 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})">×</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, "'")}'>${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> |
|
|