Download index.html from usmar/flickpick: direct link, hf CLI and curl.
- Browser
- Download file 8.94 kB
-
https://huggingface.co/spaces/usmar/flickpick/resolve/main/index.html
- Command line
-
hf download hf://spaces/usmar/flickpick/index.html
-
curl -L -o index.html https://huggingface.co/spaces/usmar/flickpick/resolve/main/index.html
8.94 kB
| <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 = []; // {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})">×</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> | |