| """ |
| Hackathon Roulette — spin through the Build Small Hackathon and discover |
| projects you'd never find by scrolling. Categorizes every submission and |
| lets you roulette your way to hidden gems (with in-session memory of what |
| you've already seen). |
| """ |
|
|
| import random |
| import re |
| import time |
| import html as _html |
|
|
| import gradio as gr |
| from huggingface_hub import HfApi |
|
|
| |
|
|
| HACKATHON_ORG = "build-small-hackathon" |
| HACKATHON_NAME = "Build Small Hackathon" |
|
|
| |
| INFRA_SPACES = {"field-guide", "readme", "registration"} |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| TAXONOMY = [ |
| ("Games, RPGs & Interactive Fiction", "🎮", ["game","rpg","roguelike","roguelite","deckbuild","dungeon","escape room","escape-room","text-adventure","text adventure","adventure","misadventure","interactive-fiction","interactive fiction","visual novel","visual-novel","ttrpg","tabletop","whodunit","detective","courtroom","tribunal","verdict","jury","puzzle","riddle","arcade","poker","chess","mafia","duel","arena","akinator","backrooms","parkour","phaser","playable","gameplay","roleplay","role-play","reverse-turing","reverse turing","trolley","board game","deathmatch","choose your","trivia","survival","maze","wordle","sudoku","betting","dating sim","platformer","sokoban","tower defense","idle game","mystery","heist","casino","card game","gamif","goblin","brawl","argue","debate","karate","kung fu","ninja","samurai","dota","doom","emulat","gameboy","game boy","nintendo","n64","royale","worldcup","world cup","objection","trial","arcane","fighter","battle","combat","boss fight","zombie","monster","tycoon","racing","racer","wrestl","boxing","party game","interrogat","dare","escape the","you are a god","play as","npc","pixel game","bullet hell","stealth game","sandbox game","grid royale","party","speak to your manager","chaos goblin","worst","you decide"]), |
| ("Worlds, Sims & Multi-Agent Societies", "🌍", ["multi-agent","multi agent","simulation","simulator","persistent world","world-build","world building","world-building","world model","colony","civilization","civilisation","city-builder","city builder","village","smol town","ecosystem","terraform","self-play","agent-simulation","trading firm","rumor economy","gossip economy","tiny civilization","living forest","sandbox","life sim","tamagotchi","god game","council","swarm","terrarium","parliament","senate","society","kingdom","empire","dynasty","planet","universe","galaxy","cosmos","forest","organism","flock","hive mind","tiny minds","mastermind","multi-model","multi model","agent society","emergent","cellular automata","game of life","conway","ant colony","aquarium","habitat","biome"]), |
| ("Health, Medical & Wellness", "🩺", ["medical","clinical","clinic","healthcare","health companion","medgemma","patient","symptom","diagnos","radiology","x-ray","dental","pharmacy","pharma","prescription","medicine","blood test","nutrition","nutri","pregnan","caregiver","elderly","parkinson","stroke","vaccine","neoantigen","first aid","physiotherap","cardiac","heartbeat","variant effect","prediction engine","mental-health","mental health","emotional support","anxiety","therapy","wellness","grief","adhd","neurodiverg","allergen","disease","doctor","nurse","hospital","telemedicine","surgery","surgeon","dentist","cancer","tumor","tumour","diabet","insulin","glucose","blood pressure","asthma","skin","derma","biosignal","biomarker","genom","dna","protein","molecul","metaboli","drug","medication","rehab","addiction","depression","autism","fitness","workout","exercise","meditat","sleep tracker","calorie","diet","vitamin","supplement","counsel","mri","ultrasound","spoilage"]), |
| ("Tutors, Study & Language Learning", "🎓", ["tutor","study buddy","study partner","studybuddy","study copilot","homework","exam","flashcard","quiz","socratic","lesson","teaching","teach an","teach the","classroom","kids","children","preschool","toddler","language-learning","language learning","language practice","lingo","learn korean","learn japanese","learn languages","learn english","english expression","duolingo","reading buddy","read-along","read along","dyslexi","iep","whiteboard","cbse","ncert","samacheer","scholar","literature-review","interview coach","interview prep","speech coach","running coach","gym coach","curriculum","educational","flashcard generator","spaced repetition","vocabulary","grammar","mnemonic","coach","mentor","study","math","physics","chemistry","biology","school","student","academic","university","college","mandarin","spanish","french","german","japanese","sentence","essay","spelling","explainer","encyclopedia","admission","scholarship","upskill","tutorial","counsellor","counselor","exam prep","study abroad","memoriz","practice partner","skill builder","wikipedia"]), |
| ("Finance, Documents & Business", "💼", ["invoice","accounting","bookkeep","ledger","receipt","expense","budget","taxes","tax filing","tax season","finops","finance","financ","financial","stocks","stock market","trading","alpha signal","sec filing","insurance","crm","inventory","udhaar","shopkeeper","kirana","dukaan","sales","pricing","price comparison","real-estate","real estate","ads advisor","resume","job description","reconcil","lease","legal","legislation","contract","bureaucra","immigration","compliance","ocr","document-ai","text-to-sql","spreadsheet","rfq","payroll","procurement","gig work","freelance","order desk","orders","retail","shop","store","marketplace","ecommerce","e-commerce","wallet","payment","money","salary","wage","startup","business","entrepreneur","marketing","advertis","customer","revenue","profit","bank","loan","mortgage","crypto","portfolio","market","economy","logistics","supply chain","warehouse","dispatch","delivery","vendor","quote","estimate","proposal","pdf","paperwork","report generat","dashboard","analytics","excel","csv","data analysis","structured data","audit","job","hiring","recruit","applicant","rejection","pitch deck","pitch practice","operations","letter","decoder","finops","fin ops","viscosity","controller"]), |
| ("Scan, Vision & Physical-World Utilities", "📸", ["plant","garden","crop","weed","herbicide","farm","beekeep","honeybee","apiar","soil","satellite","eurosat","land-use","solar","weather","watering","fridge","recipe","meal plan","cook","ingredient","food waste","upcycl","mushroom","forager","edible","stray animal","wardrobe","outfit","fashion","stylist","car diagnostic","appliance fault","repair","defect","building-inspection","road-defect","object-detection","yolo","vlm","vision-language","computer-vision","image classif","segment","label lens","posture","barcode","football","soccer","referee","sports","photo","photograph","camera","scan","snap","detect","recognition","classif","classifier","identif","vision","map","maps","gps","navigation","travel","route","drone","aircraft","vehicle","automobile","traffic","parking","animal","bird","insect","wildlife","species","fish","pet ","dog","cat ","kitten","food","kitchen","waste","recycl","agriculture","3d print","3d-print","printer","factory","manufactur","mechanic","machine fault","maintenance","inspection","sensor","iot","gesture","augmented reality","junk drawer","aircraft rarity"]), |
| ("Safety, Security & Privacy", "🛡️", ["scam","phishing","fraud","online-safety","online safety","cybersec","security","soc analyst","osint","threat","vuln","pii","privacy","redact","guardrail","firewall","dark pattern","deepfake","captcha","jailbreak","safety-eval","prompt-injection","airlock","malware","ransomware","hacker","exploit","encrypt","decrypt","password","authentication","biometric","surveillance","moderation","toxic","abuse","harassment","nsfw","misinformation","fact check","fake news","watermark","ai detector","deepverify","scrub","sensitive leak","content filter","redac","anonymiz"]), |
| ("Dev, Code, Robotics & Model Tooling", "⚙️", ["code review","code-review","code-generation","commit","repo","github","mermaid","flowchart","diagram","debug","rubber duck","rubber-duck","pull request","refactor","decompil","gpu kernel","gpu-kernel","fine-tune","fine-tuning","distill","quantiz","benchmark","structured json","data-engineering","excalidraw","skill router","skill-router","formal-verification","lean4","mcp-server","devops","reachy","robot","esp32","arduino","onnx","webgpu","transformers.js","transformers-js","rl-environment","openenv","grpo","interpretab","surprisal","next-token","abliterat","tokenizer","regex","evaluation pipeline","llm eval","sql query","coding","developer","software","api ","cli ","terminal","shell","bash","python","javascript","database","backend","frontend","kubernetes","deploy","unit test","compile","kernel","vram","cuda","inference","latency","throughput","embedding","neural","pytorch","wasm","plugin","firmware","observability","monitoring","logging","codebase","cognitive architecture","evaluat","benchmark suite","prompt engineer","activation","emulator","vla ","policy network","skill","recap and shell"]), |
| ("Voice, Speech, Music & Translation", "🎙️", ["voice","music","radio","voice-clon","voice clon","tts","text-to-speech","asr","speech-to-text","speech-to-speech","speech-translation","whisper","voxcpm","kokoro","f5-tts","fastconformer","dubbing","narration","narrator","read aloud","read-aloud","audiobook","podcast","music-generation","music generator","lofi","lo-fi","lullab","singalong","karaoke","lyric","sheet music","transcrib","pronunciation","recitation","sign language","sign-language","sign2voice","dictation","translation","translate","translat","spoken word","song","melody","chorus","beat","rhythm","remix","compose","composer","composition","instrument","instrumental","piano","guitar","drum","violin","saxophone","synth","vocal","singer","singing","soundtrack","midi","harmony","chord","ambient","playlist","jingle","anthem","acoustic","audio","sound","speech","spoken","multilingual","subtitle","caption","accent","band","music video"]), |
| ("Story, Image & Creative Generation", "🎨", ["story","stories","storybook","storytell","bedtime","fairytale","fairy tale","picture book","comic","manga","illustrat","poem","poetry","poetic","novel","fable","creative-writing","creative writing","narrative","text-to-image","image-generation","image generation","img2img","image-to-image","diffusion","flux","sd-turbo","sticker","pixel-art","pixel art","typograph","voxel","avatar","manim","animation","text-to-video","image-to-video","claymation","postcard","sketch","doodle","watercolour","minecraftify","generative-art","kinetic typography","icon generator","logo","wallpaper","emoji","meme","theater","theatre","puppet","myth","legend","fantasy","witch","wizard","dragon","coloring","collage","video","film","tale","tales","draw","drawing","paint","painting","artist","design","designer","creative","render","scene","webtoon","poster","greeting card","album cover","cover art","font","3d","canvas","author","quill","writing","write","clip","broadcast","movie","cinema","gif","caricature","portrait"]), |
| ("Companions, Personas & Reflective Toys", "💚", ["companion","waifu","persona","character chat","character with","in character","virtual character","desktop pet","floating desktop","virtual pet","tsundere","yandere","girlfriend","confidant","journaling","voice journal","mood journal","gratitude journal","daily journal","reflection","oneiro","oracle","divination","prophecy","fortune teller","magic 8","tarot","arcana","seance","séance","occult","constellation","lives unlived","unlived","parallel life","museum of","elegy","afterlife","loneliness","mindful","stargazing","imaginary friend","pen pal","penpal","diary","gratitude","affirmation","horoscope","zodiac","astrolog","emotion","feeling","mood","empath","friend","buddy","familiar","creature","soul","spirit","ghost","fairy","imaginary","comfort","vent","confession","secret","wish","memory","nostalgia","heart","love","romance","crush","relationship","tombstone","raise a","baby ai","overthink","decompress","dream","temper","inner voice","alter ego"]), |
| ("Assistants, Agents & Productivity", "💬", ["assistant","co-pilot","copilot","chatbot","chat assistant","chat with","agent","helper","concierge","butler","productivity","to-do","todo","task manager","note-taking","note taking","notetaking","meeting notes","summari","email","inbox","calendar","schedul","planner","reminder","advisor","recommend","knowledge base","knowledge hub","knowledgehub","second brain","retrieval-augmented","rag ","search engine","web search","personal ai","local ai","offline ai","home assistant","conversational","workflow","support","docu","research assistant","news","digest","faq","automation","ai","app","tool","chat","bot","gpt","llm","small model","tiny model","local model","on-device","mini","tiny","smol","helps you","help you","capture","recall","organize","tracker","logbook","search","ask","query","answer","wiki","scrape","crawl","pipeline","integration","slack","discord","telegram","whatsapp","decision","prompt","model","plan","vibe","gemma","llama","qwen","nemotron","minicpm","smol","context","studio","powered","pocket","forge","32b","14b","8b","mind"]), |
| ] |
|
|
| OTHER_NAME, OTHER_EMOJI = "Other", "🎁" |
|
|
| CAT_EMOJI = {name: emoji for name, emoji, _ in TAXONOMY} |
| CAT_EMOJI[OTHER_NAME] = OTHER_EMOJI |
| CAT_EMOJI["All"] = "🎲" |
|
|
| |
| ALL_CATEGORIES = ["All"] + [name for name, _, _ in TAXONOMY] + [OTHER_NAME] |
|
|
| |
| _COMPILED = [(name, [re.compile(r"\b" + re.escape(k)) for k in kws]) |
| for name, _, kws in TAXONOMY] |
| _CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") |
|
|
| |
| _NOISE_TOK = { |
| "gradio", "docker", "static", "build-small-hackathon", "backyard-ai", |
| "backyard ai", "tiny-titan", "tiny titan", "off-the-grid", "offgrid", |
| "nemotron", "minicpm", "llama-cpp", "openbmb", "zerogpu", "transformers", |
| "nvidia", "modal", "openai", "region:us", |
| } |
|
|
| |
| _TRACK_MAP = { |
| "track:backyard": "Backyard", "track:backyard-ai": "Backyard", |
| "track:wood": "Thousand Token Wood", |
| "track:thousand-token-wood": "Thousand Token Wood", |
| "track:thousand_token_wood": "Thousand Token Wood", |
| } |
|
|
| |
|
|
| def _categorize(title: str, raw: str, desc: str, tags: list) -> str: |
| keep = [t for t in tags if t.lower() not in _NOISE_TOK] |
| blob = f"{title} {raw.replace('-', ' ').replace('_', ' ')} {desc} {' '.join(keep)}" |
| blob = _CAMEL.sub(" ", blob).lower() |
| for name, pats in _COMPILED: |
| if any(p.search(blob) for p in pats): |
| return name |
| return OTHER_NAME |
|
|
|
|
| def _track_of(tags: list) -> str: |
| for t in tags: |
| lbl = _TRACK_MAP.get(t.lower()) |
| if lbl: |
| return lbl |
| return "" |
|
|
| |
|
|
| _CACHE: list | None = None |
| _CACHE_TS: float = 0.0 |
| CACHE_TTL = 600 |
|
|
| |
| _STATS = {"listed": 0, "private": 0, "infra": 0, "pool": 0} |
|
|
|
|
| def _card_data(space) -> dict: |
| """Robustly pull the card dict — SpaceCardData is NOT a plain dict on |
| modern huggingface_hub, so isinstance(dict) would silently drop everything.""" |
| cd = getattr(space, "cardData", None) |
| if cd is None: |
| return {} |
| if hasattr(cd, "to_dict"): |
| try: |
| return cd.to_dict() |
| except Exception: |
| pass |
| if isinstance(cd, dict): |
| return dict(cd) |
| return {} |
|
|
|
|
| def load_spaces(force: bool = False) -> list: |
| """Index every PUBLIC submission in the hackathon org. |
| |
| Private/gated spaces are skipped on purpose: they aren't in the public |
| listing, they'd be dead links for anyone the roulette sends there, and |
| skipping them makes the pool identical whether or not an HF token is |
| present in the environment — so the draw is fair and reproducible. |
| """ |
| global _CACHE, _CACHE_TS |
| now = time.time() |
| if not force and _CACHE is not None and (now - _CACHE_TS) < CACHE_TTL: |
| return _CACHE |
|
|
| api = HfApi() |
| spaces: list = [] |
| listed = private = infra = 0 |
| try: |
| for s in api.list_spaces(author=HACKATHON_ORG, full=True): |
| listed += 1 |
| if getattr(s, "private", False): |
| private += 1 |
| continue |
| raw = s.id.split("/")[-1] if "/" in s.id else s.id |
| if raw.lower() in INFRA_SPACES: |
| infra += 1 |
| continue |
|
|
| cd = _card_data(s) |
| title = str(cd.get("title") or "").strip() |
| desc = str(cd.get("short_description") or cd.get("description") or "").strip()[:320] |
| sdk = str(cd.get("sdk") or "").strip() |
| tags = list(s.tags or []) |
| author = s.author or (s.id.split("/")[0] if "/" in s.id else "?") |
|
|
| spaces.append({ |
| "id": s.id, |
| "name": title or raw.replace("-", " ").replace("_", " ").title(), |
| "raw_name": raw, |
| "author": author, |
| "tags": tags, |
| "likes": int(getattr(s, "likes", 0) or 0), |
| "url": f"https://huggingface.co/spaces/{s.id}", |
| "desc": desc, |
| "sdk": sdk, |
| "track": _track_of(tags), |
| "category": _categorize(title, raw, desc, tags), |
| }) |
| except Exception as exc: |
| print(f"[hackathon-roulette] load error: {exc}") |
|
|
| |
| |
| if spaces or _CACHE is None: |
| _CACHE = spaces |
| _CACHE_TS = now |
| if spaces: |
| _STATS.update(listed=listed, private=private, infra=infra, pool=len(spaces)) |
| return _CACHE |
|
|
|
|
| def _bucket(spaces: list, cat: str) -> list: |
| return [s for s in spaces if cat == "All" or s["category"] == cat] |
|
|
|
|
| def _display_tags(sp: dict) -> list: |
| out = [] |
| for t in sp["tags"]: |
| tl = t.lower() |
| if tl in _NOISE_TOK: |
| continue |
| if t.startswith(("region:", "license:", "arxiv:", "doi:", "track:", |
| "sponsor:", "achievement:", "base_model:", "dataset:", |
| "pipeline_tag:", "badge-", "badge:")): |
| continue |
| out.append(t) |
| return out |
|
|
| |
|
|
| def _stats(seen: list, spaces: list, cat: str) -> str: |
| seen_set = set(seen) |
| bucket = _bucket(spaces, cat) |
| total = len(bucket) |
| found = sum(1 for s in bucket if s["id"] in seen_set) |
| left = total - found |
| pct = round(found / total * 100) if total else 0 |
|
|
| cats_html = "" |
| if cat == "All": |
| counts = {} |
| for s in spaces: |
| counts[s["category"]] = counts.get(s["category"], 0) + 1 |
| ordered = [(n, e, counts.get(n, 0)) for n, e, _ in TAXONOMY] |
| ordered.append((OTHER_NAME, OTHER_EMOJI, counts.get(OTHER_NAME, 0))) |
| cats_html = "".join( |
| f'<span class="cat-count" title="{_html.escape(n)}">{e} {c}</span>' |
| for n, e, c in ordered if c |
| ) |
|
|
| return f""" |
| <div class="stats-bar"> |
| <div class="stat-row"> |
| <div class="stat-item"><span class="snum">{total}</span><span class="slbl">spaces</span></div> |
| <span class="sdot">·</span> |
| <div class="stat-item"><span class="snum" style="color:#4ade80">{found}</span><span class="slbl">seen</span></div> |
| <span class="sdot">·</span> |
| <div class="stat-item"><span class="snum" style="color:#60a5fa">{left}</span><span class="slbl">to go</span></div> |
| <div class="pbar-wrap"><div class="pbar-fill" style="width:{pct}%"></div></div> |
| <span class="pbar-lbl">{pct}%</span> |
| </div> |
| {_why_note() if cat == "All" else ""} |
| {f'<div class="cat-counts">{cats_html}</div>' if cats_html else ""} |
| </div>""" |
|
|
|
|
| def _why_note() -> str: |
| pool = _STATS.get("pool", 0) |
| infra = _STATS.get("infra", 0) |
| priv = _STATS.get("private", 0) |
| listed = _STATS.get("listed", 0) or (pool + infra + priv) |
| if not listed: |
| return "" |
| pub = listed - priv |
| info = (f"<strong>{infra}</strong> org info page" + ("s" if infra != 1 else "") |
| + " (<em>field-guide</em> & <em>README</em>)") |
| if priv: |
| |
| |
| head = f"ⓘ why {pool}, not {listed}?" |
| body = (f"The org has <strong>{listed}</strong> Spaces in total. " |
| f"<strong>{priv}</strong> are private or test spaces (so the public page " |
| f"shows <strong>{pub}</strong>), and {info} aren't competition entries — " |
| f"leaving <strong>{pool}</strong> real, public submissions to spin through.") |
| else: |
| |
| |
| head = f"ⓘ {pool} public submissions" |
| body = (f"Every public project the Hub's API serves, minus {info}. Private and " |
| f"test Spaces — plus a handful the org page counts that the public API " |
| f"doesn't return — aren't included.") |
| return f""" |
| <div class="why-line"> |
| <span class="why-chip" tabindex="0">{head} |
| <span class="why-pop">{body}</span> |
| </span> |
| </div>""" |
|
|
|
|
| def _placeholder(msg: str = "", spin_count: int | None = None) -> str: |
| inner = msg or "Pull the lever — hit <strong>SPIN</strong> to discover a random project!" |
| |
| |
| ds = f' data-spin="{spin_count}"' if spin_count is not None else "" |
| return f""" |
| <div class="ph-wrap"{ds}> |
| <div class="ph-dice">🎰</div> |
| <p class="ph-txt">{inner}</p> |
| </div>""" |
|
|
|
|
| def _card(sp: dict, spin_count: int, footer: str = "") -> str: |
| e = _html.escape |
| cat = sp["category"] |
| em = CAT_EMOJI.get(cat, "🎁") |
|
|
| chips = "".join(f'<span class="tag-chip">{e(t)}</span>' for t in _display_tags(sp)[:7]) |
| sdk_b = f'<span class="sdk-pill">{e(sp["sdk"])}</span>' if sp.get("sdk") else "" |
| track_b = f'<span class="track-pill">🌲 {e(sp["track"])}</span>' if sp.get("track") else "" |
|
|
| desc = sp.get("desc", "").strip() |
| desc_html = e(desc) if desc else "<em class='nodesc'>No description provided — open it to find out!</em>" |
|
|
| |
| |
| |
| |
| return f""" |
| <div class="space-card" data-spin="{spin_count}"> |
| <div class="card-shine"></div> |
| <div class="card-meta"> |
| <span class="cat-pill">{em} {e(cat)}</span> |
| {track_b}{sdk_b} |
| <span class="likes-pill">❤️ {sp['likes']}</span> |
| </div> |
| <h2 class="card-h">{e(sp['name'])}</h2> |
| <p class="card-by">by <strong>{e(sp['author'])}</strong></p> |
| <p class="card-desc">{desc_html}</p> |
| <div class="card-chips">{chips}</div> |
| <div class="card-foot"> |
| <a href="{sp['url']}" target="_blank" rel="noopener" class="open-btn">🚀 Open Space</a> |
| <span class="draw-note">{footer}</span> |
| </div> |
| </div>""" |
|
|
|
|
| def _browse_list(spaces: list, cat: str, seen: list) -> str: |
| bucket = sorted(_bucket(spaces, cat), key=lambda s: -s["likes"]) |
| if not bucket: |
| return '<p class="browse-empty">Nothing here yet.</p>' |
|
|
| seen_set = set(seen) |
| CAP = 60 |
| rows = [] |
| for s in bucket[:CAP]: |
| e = _html.escape |
| seen_mark = '<span class="seen-dot" title="already seen">✓</span>' if s["id"] in seen_set else "" |
| em = CAT_EMOJI.get(s["category"], "🎁") |
| rows.append(f""" |
| <a class="browse-row" href="{s['url']}" target="_blank" rel="noopener"> |
| <span class="br-em">{em}</span> |
| <span class="br-name">{e(s['name'])}{seen_mark}</span> |
| <span class="br-author">{e(s['author'])}</span> |
| <span class="br-likes">❤️ {s['likes']}</span> |
| </a>""") |
| more = "" |
| if len(bucket) > CAP: |
| more = f'<p class="browse-more">+ {len(bucket) - CAP} more — spin or pick a tighter category to see them</p>' |
| return f'<div class="browse-wrap">{"".join(rows)}{more}</div>' |
|
|
| |
|
|
| def _find(spaces: list, sid: str): |
| return next((s for s in spaces if s["id"] == sid), None) |
|
|
|
|
| def on_load(seen: list): |
| spaces = load_spaces() |
| n = len(spaces) |
| |
| |
| valid = {s["id"] for s in spaces} |
| seen = [sid for sid in dict.fromkeys(seen or []) if sid in valid] |
| if not n: |
| msg = "Couldn't reach the Hub right now — try refreshing in a moment." |
| elif seen: |
| msg = (f"👋 Welcome back — you've already seen <strong>{len(seen)}</strong> of " |
| f"<strong>{n}</strong> projects. Hit SPIN for one you haven't!") |
| else: |
| msg = f"Loaded <strong>{n}</strong> projects from the {HACKATHON_NAME} — ready to roll! 🎉" |
| |
| return (seen, "All", 0, [], -1, |
| _stats(seen, spaces, "All"), _placeholder(msg), gr.update(interactive=False)) |
|
|
|
|
| def on_spin(seen: list, cat: str, spin_count: int, hist: list, pos: int): |
| spaces = load_spaces() |
| new_count = spin_count + 1 |
| bucket = _bucket(spaces, cat) |
| if not bucket: |
| |
| return (_placeholder("No projects in this category.", new_count), seen, new_count, |
| _stats(seen, spaces, cat), hist, pos, gr.update(interactive=pos > 0)) |
|
|
| seen_set = set(seen) |
| unseen = [s for s in bucket if s["id"] not in seen_set] |
| banner = "" |
| if not unseen: |
| bucket_ids = {s["id"] for s in bucket} |
| seen = [sid for sid in seen if sid not in bucket_ids] |
| unseen = bucket |
| banner = '<div class="reset-note">🎉 You\'ve seen every project here — reshuffling the deck!</div>' |
|
|
| |
| |
| cur_id = hist[pos] if 0 <= pos < len(hist) else None |
| pool = [s for s in unseen if s["id"] != cur_id] or unseen |
| pick = random.choice(pool) |
| new_seen = list(seen) + [pick["id"]] |
| position = sum(1 for s in bucket if s["id"] in set(new_seen)) |
|
|
| |
| |
| |
| new_hist = list(hist[:pos + 1]) + [pick["id"]] |
| new_pos = len(new_hist) - 1 |
|
|
| card = banner + _card(pick, new_count, f"draw #{position} of {len(bucket)}") |
| return (card, new_seen, new_count, _stats(new_seen, spaces, cat), |
| new_hist, new_pos, gr.update(interactive=new_pos > 0)) |
|
|
|
|
| def on_back(hist: list, pos: int, spin_count: int): |
| """Re-show the project viewed just before the current one.""" |
| spaces = load_spaces() |
| new_count = spin_count + 1 |
| |
| |
| |
| new_pos = pos - 1 if (pos > 0 and hist) else max(pos, 0) |
| if not hist: |
| return _placeholder("Spin first!", new_count), pos, new_count, gr.update(interactive=False) |
|
|
| sp = _find(spaces, hist[new_pos]) if 0 <= new_pos < len(hist) else None |
| if sp is None: |
| card = _placeholder("That project is no longer available.", new_count) |
| else: |
| card = _card(sp, new_count, f"↩ back-spin · {new_pos + 1} of {len(hist)}") |
| return card, new_pos, new_count, gr.update(interactive=new_pos > 0) |
|
|
|
|
| def on_category(cat: str, seen: list): |
| |
| |
| spaces = load_spaces() |
| return cat, _stats(seen, spaces, cat) |
|
|
|
|
| def on_browse(cat: str, seen: list): |
| spaces = load_spaces() |
| return _browse_list(spaces, cat, seen) |
|
|
| |
|
|
| CSS = r""" |
| /* ── aurora backdrop ── */ |
| body, gradio-app { background:#070714 !important; } |
| .gradio-container { background: transparent !important; max-width: 760px !important; margin: 0 auto !important; position: relative; z-index: 1; } |
| body::before { |
| content:''; position:fixed; inset:-30%; z-index:0; pointer-events:none; filter: blur(22px); |
| background: |
| radial-gradient(38% 38% at 20% 22%, #7c3aed30, transparent 60%), |
| radial-gradient(36% 36% at 82% 18%, #2563eb2b, transparent 60%), |
| radial-gradient(44% 44% at 72% 82%, #f472b624, transparent 60%), |
| radial-gradient(40% 40% at 22% 86%, #22d3ee1f, transparent 60%); |
| animation: aurora 24s ease-in-out infinite alternate; |
| } |
| @keyframes aurora { |
| 0% { transform: translate3d(-3%,-2%,0) rotate(0deg) scale(1.05); } |
| 50% { transform: translate3d(3%, 2%,0) rotate(7deg) scale(1.16); } |
| 100% { transform: translate3d(-2%,3%,0) rotate(-6deg) scale(1.08); } |
| } |
| footer { display: none !important; } |
| |
| .app-header { text-align: center; padding: 2rem 1rem 0.4rem; position: relative; } |
| .hdr-icon { font-size: 3.1rem; display: block; margin-bottom: .25rem; |
| animation: pop 1s cubic-bezier(.175,.885,.32,1.275) both; |
| filter: drop-shadow(0 0 14px #7c3aed88); } |
| @keyframes pop { 0%{transform:scale(0) rotate(-200deg);opacity:0} 75%{transform:scale(1.18) rotate(10deg)} 100%{transform:scale(1) rotate(0);opacity:1} } |
| .hdr-title { font-size: 2rem; font-weight: 900; margin: .1rem 0 .35rem; |
| background: linear-gradient(120deg,#a78bfa,#60a5fa 50%,#f472b6); |
| background-size: 200% auto; -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| background-clip: text; animation: shimmer 6s linear infinite; } |
| @keyframes shimmer { to { background-position: 200% center; } } |
| .hdr-sub { color: #9292c6; font-size: .87rem; margin: 0; } |
| .hdr-sub strong { color: #b8b8e0; } |
| |
| /* ── mute toggle ── */ |
| .rl-mute { position:absolute; top:.4rem; right:.2rem; background:#0f0f28; border:1px solid #242456; |
| color:#c0c0e8; border-radius:10px; width:38px; height:38px; font-size:1.05rem; cursor:pointer; |
| transition:transform .12s, border-color .15s, box-shadow .2s; z-index:3; } |
| .rl-mute:hover { transform:scale(1.08); border-color:#7c3aed; box-shadow:0 0 14px #7c3aed55; } |
| .rl-mute.muted { opacity:.55; } |
| |
| .stats-bar { background:#0f0f28cc; backdrop-filter:blur(6px); border:1px solid #1e1e48; border-radius:12px; padding:.6rem 1rem; margin:.5rem 0; } |
| .stat-row { display:flex; align-items:center; gap:.65rem; flex-wrap:wrap; } |
| .stat-item { display:flex; flex-direction:column; align-items:center; } |
| .snum { font-size:1.25rem; font-weight:800; color:#a78bfa; line-height:1.05; transition:color .3s; } |
| .slbl { font-size:.58rem; color:#9090c4; text-transform:uppercase; letter-spacing:.07em; } |
| .sdot { color:#1e1e48; } |
| .pbar-wrap { flex:1; min-width:60px; height:5px; background:#1e1e48; border-radius:3px; overflow:hidden; } |
| .pbar-fill { height:100%; background:linear-gradient(90deg,#7c3aed,#60a5fa,#f472b6); background-size:200% auto; transition:width .5s ease; animation:shimmer 3s linear infinite; } |
| .pbar-lbl { color:#9292c6; font-size:.74rem; font-weight:600; min-width:2.4rem; text-align:right; } |
| .cat-counts { display:flex; gap:.32rem; flex-wrap:wrap; margin-top:.55rem; padding-top:.55rem; border-top:1px solid #16163a; } |
| .cat-count { background:#0a0a20; border:1px solid #1e1e48; border-radius:7px; padding:.1rem .42rem; font-size:.72rem; color:#9a9acc; transition:transform .1s, border-color .15s; } |
| .cat-count:hover { transform:translateY(-1px); border-color:#34346e; } |
| |
| /* ── why-884 tooltip ── */ |
| .why-line { margin-top:.5rem; padding-top:.5rem; border-top:1px solid #16163a; } |
| .why-chip { position:relative; display:inline-block; font-size:.74rem; color:#9a9acc; |
| background:#0a0a20; border:1px solid #20204a; border-radius:8px; padding:.12rem .5rem; cursor:help; outline:none; } |
| .why-chip:hover, .why-chip:focus { border-color:#7c3aed; color:#a78bfa; } |
| .why-pop { position:absolute; left:0; bottom:130%; width:300px; max-width:80vw; z-index:20; |
| background:#13132e; border:1px solid #34346e; border-radius:10px; padding:.6rem .75rem; |
| color:#a0a0d0; font-size:.78rem; line-height:1.5; text-align:left; box-shadow:0 8px 30px #000a; |
| opacity:0; transform:translateY(4px); pointer-events:none; transition:opacity .15s, transform .15s; } |
| .why-pop strong { color:#c8c8f0; } .why-pop em { color:#5fd58b; font-style:normal; } |
| .why-chip:hover .why-pop, .why-chip:focus .why-pop { opacity:1; transform:translateY(0); } |
| |
| /* ── SPIN button: glow + spin states ── */ |
| #spin-btn, #spin-btn button { background:linear-gradient(135deg,#7c3aed,#2563eb)!important; color:#fff!important; border:none!important; |
| border-radius:14px!important; font-size:1.3rem!important; font-weight:900!important; letter-spacing:.1em!important; |
| transition:transform .12s, box-shadow .2s!important; min-height:54px!important; animation: glow 2.6s ease-in-out infinite; } |
| @keyframes glow { 0%,100%{ box-shadow:0 4px 22px rgba(124,58,237,.45); } 50%{ box-shadow:0 4px 30px rgba(124,58,237,.7), 0 0 16px rgba(96,165,250,.45); } } |
| #spin-btn:hover, #spin-btn button:hover { transform:translateY(-2px)!important; box-shadow:0 7px 34px rgba(124,58,237,.75)!important; } |
| #spin-btn:active, #spin-btn button:active { transform:translateY(0) scale(.97)!important; } |
| #spin-btn.spinning, #spin-btn.spinning button { animation: glow-fast .45s ease-in-out infinite; } |
| @keyframes glow-fast { 0%,100%{ box-shadow:0 4px 26px rgba(124,58,237,.7); } 50%{ box-shadow:0 6px 44px rgba(244,114,182,.85), 0 0 28px rgba(96,165,250,.7); } } |
| |
| /* ── Back Spin button — subtler sibling of SPIN, disabled until there's history ── */ |
| #back-btn, #back-btn button { background:#13132e!important; color:#a78bfa!important; border:1px solid #34346e!important; |
| border-radius:14px!important; font-size:1rem!important; font-weight:800!important; letter-spacing:.04em!important; |
| transition:transform .12s, box-shadow .2s, opacity .2s, border-color .2s!important; min-height:54px!important; } |
| #back-btn:not(:disabled):hover, #back-btn:not(:disabled) button:hover { transform:translateY(-2px)!important; border-color:#7c3aed!important; box-shadow:0 6px 22px rgba(124,58,237,.4)!important; } |
| #back-btn:not(:disabled):active, #back-btn:not(:disabled) button:active { transform:translateY(0) scale(.97)!important; } |
| #back-btn:disabled, #back-btn button:disabled, #back-btn[disabled] { opacity:.38!important; cursor:not-allowed!important; box-shadow:none!important; transform:none!important; } |
| #back-btn.spinning, #back-btn.spinning button { animation: glow-fast .4s ease-in-out infinite; border-color:#7c3aed!important; } |
| |
| #cat-dd label span { color:#9292c6!important; font-size:.78rem!important; } |
| #cat-dd input, #cat-dd .wrap { background:#0f0f28!important; border-color:#1e1e48!important; color:#c0c0e8!important; } |
| |
| /* ── slot reel (shown while waiting for the draw) ── */ |
| #card-out { position: relative; } |
| .rl-reel-overlay { position:absolute; inset:0; min-height:172px; z-index:40; display:flex; |
| align-items:center; justify-content:center; background:#0a0a1c; border-radius:16px; |
| animation: ov-in .12s ease both; } |
| @keyframes ov-in { from { opacity:.35; } to { opacity:1; } } |
| .rl-reel-overlay .reel-wrap { padding:1rem; } |
| .reel-wrap { text-align:center; padding:2.2rem 1rem; } |
| .reel { width:100%; max-width:340px; height:78px; margin:0 auto; overflow:hidden; position:relative; |
| border:1px solid #2a2a5c; border-radius:14px; background:#0c0c22; |
| box-shadow:inset 0 0 30px #000, 0 0 26px #7c3aed66; } |
| .reel::before, .reel::after { content:''; position:absolute; top:0; bottom:0; width:46px; z-index:2; pointer-events:none; } |
| .reel::before { left:0; background:linear-gradient(90deg,#0c0c22,transparent); } |
| .reel::after { right:0; background:linear-gradient(270deg,#0c0c22,transparent); } |
| .reel-strip { display:inline-block; white-space:nowrap; font-size:2.7rem; line-height:78px; |
| animation: reel-roll .42s linear infinite; filter: blur(.3px); will-change: transform; } |
| .reel.reel-back .reel-strip { animation-direction: reverse; } /* rewind scrolls the other way */ |
| @keyframes reel-roll { from { transform:translateX(0); } to { transform:translateX(-2em); } } |
| .reel-txt { color:#7c7cb8; margin-top:.85rem; font-size:.92rem; letter-spacing:.05em; animation: pulse-txt 1s ease-in-out infinite; } |
| @keyframes pulse-txt { 0%,100%{ opacity:.45; } 50%{ opacity:1; } } |
| |
| /* ── result card ── */ |
| .space-card { background:#0f0f28; border:1px solid #242456; border-radius:16px; padding:1.5rem; margin-top:.7rem; |
| position:relative; overflow:hidden; transform-origin:center top; |
| animation: card-drop .55s cubic-bezier(.175,.885,.32,1.275) both; } |
| .space-card::before { content:''; position:absolute; inset:0; z-index:0; border-radius:16px; background:linear-gradient(135deg,#7c3aed14,#2563eb14,#f472b614); pointer-events:none; } |
| @keyframes card-drop { 0%{opacity:0;transform:translateY(26px) scale(.94) rotateX(9deg);filter:blur(5px)} 60%{opacity:1;transform:translateY(-6px) scale(1.015)} 100%{opacity:1;transform:translateY(0) scale(1);filter:blur(0)} } |
| .card-shine { position:absolute; top:0; left:-60%; width:50%; height:100%; z-index:1; pointer-events:none; |
| background:linear-gradient(105deg, transparent, #ffffff22 45%, #ffffff33 50%, transparent 60%); |
| animation: shine 1.1s ease-out .25s both; } |
| @keyframes shine { from { left:-60%; } to { left:130%; } } |
| .space-card > *:not(.card-shine) { position:relative; z-index:2; } |
| |
| .card-meta { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; margin-bottom:.85rem; position:relative; z-index:2; } |
| .cat-pill { background:#17173a; border:1px solid #34346e; border-radius:10px; padding:.2rem .6rem; font-size:.76rem; color:#a78bfa; } |
| .track-pill { background:#0c1f12; border:1px solid #1c4a2c; border-radius:10px; padding:.2rem .6rem; font-size:.72rem; color:#5fd58b; } |
| .sdk-pill { background:#0a1420; border:1px solid #1a3050; border-radius:10px; padding:.2rem .6rem; font-size:.7rem; color:#6aa8e0; text-transform:uppercase; letter-spacing:.04em; } |
| .likes-pill { margin-left:auto; color:#f472b6; font-weight:700; font-size:.86rem; } |
| .card-h { font-size:1.5rem; font-weight:900; color:#e6e6ff; margin:0 0 .15rem; line-height:1.18; position:relative; z-index:2; } |
| .card-by { color:#9090c4; font-size:.86rem; margin:0 0 .8rem; position:relative; z-index:2; } |
| .card-by strong { color:#b8b8e0; } |
| .card-desc { color:#9c9cd2; font-size:.95rem; line-height:1.6; margin:0 0 1rem; position:relative; z-index:2; } |
| .card-desc .nodesc { color:#8c8cc2; } |
| .card-chips { display:flex; flex-wrap:wrap; gap:.3rem; margin-bottom:1.1rem; position:relative; z-index:2; } |
| .tag-chip { background:#080818; border:1px solid #1c1c3c; border-radius:6px; padding:.13rem .48rem; font-size:.68rem; color:#9a9acc; } |
| .card-foot { display:flex; align-items:center; gap:.8rem; } |
| .open-btn { display:inline-block; background:linear-gradient(135deg,#7c3aed,#2563eb); color:#fff!important; text-decoration:none!important; |
| border-radius:10px; padding:.55rem 1.3rem; font-weight:700; font-size:.92rem; box-shadow:0 2px 12px rgba(124,58,237,.35); transition:opacity .18s,transform .1s; } |
| .open-btn:hover { opacity:.9; transform:translateY(-1px); } |
| .draw-note { color:#8484b8; font-size:.74rem; } |
| |
| .ph-wrap { text-align:center; padding:2.6rem 1rem; color:#9090c4; } |
| .ph-dice { font-size:3.1rem; margin-bottom:.6rem; animation:float 3s ease-in-out infinite; } |
| @keyframes float { 0%,100%{transform:translateY(0) rotate(-4deg)} 50%{transform:translateY(-10px) rotate(4deg)} } |
| .ph-txt { font-size:1rem; } .ph-txt strong { color:#a78bfa; } |
| |
| .reset-note { background:#15153a; border:1px solid #7c3aed44; border-radius:10px; padding:.55rem 1rem; color:#a78bfa; font-weight:600; text-align:center; margin-bottom:.6rem; font-size:.9rem; } |
| |
| #browse-acc { margin-top:.4rem; } |
| .browse-wrap { display:flex; flex-direction:column; gap:.25rem; max-height:420px; overflow-y:auto; padding:.2rem; } |
| .browse-row { display:flex; align-items:center; gap:.6rem; padding:.45rem .6rem; border-radius:9px; background:#0c0c22; border:1px solid #17173a; text-decoration:none!important; transition:background .12s,border-color .12s; } |
| .browse-row:hover { background:#13132e; border-color:#34346e; } |
| .br-em { font-size:1rem; } |
| .br-name { color:#c8c8f0; font-weight:600; font-size:.9rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
| .seen-dot { color:#4ade80; font-size:.78rem; margin-left:.4rem; } |
| .br-author { color:#9090c4; font-size:.78rem; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
| .br-likes { color:#f472b6; font-size:.78rem; font-weight:600; } |
| .browse-more, .browse-empty { color:#9090c4; font-size:.82rem; text-align:center; padding:.6rem; } |
| """ |
|
|
| |
| |
| |
| |
| |
| |
|
|
| HEAD_HTML = r"""<script> |
| // Force dark mode for ALL components regardless of the visitor's system theme. |
| // The custom CSS only darkens our own elements; Gradio's native chrome (dropdown, |
| // accordion, labels, option popups) otherwise keeps the light theme's dark text, |
| // which is unreadable on the dark background. Adding `dark` to <html> makes Gradio |
| // apply its dark palette everywhere; a MutationObserver re-asserts it if Gradio |
| // flips it back for a light-system viewer. |
| (function forceDark() { |
| const el = document.documentElement; |
| const ensure = () => { if (!el.classList.contains('dark')) el.classList.add('dark'); }; |
| ensure(); |
| try { new MutationObserver(ensure).observe(el, { attributes: true, attributeFilter: ['class'] }); } catch (e) {} |
| })(); |
| (function () { |
| if (window.RL) return; |
| const RL = window.RL = { muted: false }; |
| let ctx = null; |
| const ac = () => { |
| try { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } |
| if (ctx && ctx.state === 'suspended') ctx.resume(); |
| return ctx; |
| }; |
| const blip = (freq, t0, dur, peak, type) => { |
| const c = ctx; if (!c) return; |
| const o = c.createOscillator(), g = c.createGain(); |
| o.type = type || 'triangle'; o.frequency.setValueAtTime(freq, t0); |
| g.gain.setValueAtTime(0.0001, t0); |
| g.gain.exponentialRampToValueAtTime(peak, t0 + 0.01); |
| g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); |
| o.connect(g); g.connect(c.destination); |
| o.start(t0); o.stop(t0 + dur + 0.02); |
| }; |
| RL.tick = (v) => { if (RL.muted || !ac()) return; blip(360 + Math.random() * 240, ctx.currentTime, 0.05, v || 0.045, 'square'); }; |
| let hov = 0; |
| RL.hover = () => { if (RL.muted) return; const n = performance.now(); if (n - hov < 90) return; hov = n; if (!ac()) return; blip(880, ctx.currentTime, 0.05, 0.02, 'sine'); }; |
| RL.chime = () => { |
| if (RL.muted || !ac()) return; |
| const base = 523.25, steps = [1, 1.25, 1.5, 2.0]; |
| steps.forEach((r, i) => blip(base * r, ctx.currentTime + i * 0.075, 0.5, 0.085, 'triangle')); |
| const c = ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain(); |
| o.type = 'sine'; o.frequency.setValueAtTime(170, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.25); |
| g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.11, t + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3); |
| o.connect(g); g.connect(c.destination); o.start(t); o.stop(t + 0.33); |
| }; |
| let spinTimer = null; |
| RL.startSpinSound = () => { |
| if (RL.muted) return; |
| let delay = 38; |
| const step = () => { RL.tick(0.045); delay = Math.min(150, delay * 1.07); spinTimer = setTimeout(step, delay); }; |
| step(); |
| }; |
| RL.stopSpinSound = () => { if (spinTimer) { clearTimeout(spinTimer); spinTimer = null; } }; |
| RL.startBackSound = () => { // descending ticks — a "rewind" counterpart to the spin |
| if (RL.muted || !ac()) return; |
| let f = 700, delay = 46; |
| const step = () => { blip(f, ctx.currentTime, 0.05, 0.04, 'square'); f = Math.max(170, f * 0.85); delay = Math.min(150, delay * 1.06); spinTimer = setTimeout(step, delay); }; |
| step(); |
| }; |
| |
| // confetti burst on a fixed overlay canvas |
| let cv, cx, parts = [], raf = null; |
| const COLORS = ['#a78bfa', '#60a5fa', '#f472b6', '#4ade80', '#fbbf24', '#22d3ee']; |
| const mkCanvas = () => { |
| if (cv) return; |
| cv = document.createElement('canvas'); cv.id = 'rl-confetti'; |
| cv.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:99999'; |
| document.body.appendChild(cv); cx = cv.getContext('2d'); |
| const rs = () => { cv.width = innerWidth * devicePixelRatio; cv.height = innerHeight * devicePixelRatio; cx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); }; |
| rs(); addEventListener('resize', rs); |
| }; |
| const loop = () => { |
| cx.clearRect(0, 0, innerWidth, innerHeight); |
| for (const p of parts) { p.vy += p.g; p.x += p.vx; p.y += p.vy; p.vx *= 0.99; p.rot += p.vr; p.life -= 0.011; } |
| parts = parts.filter(p => p.life > 0 && p.y < innerHeight + 50); |
| for (const p of parts) { cx.save(); cx.globalAlpha = Math.max(0, p.life); cx.translate(p.x, p.y); cx.rotate(p.rot); cx.fillStyle = p.col; cx.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.55); cx.restore(); } |
| raf = parts.length ? requestAnimationFrame(loop) : null; |
| }; |
| RL.burst = (x, y) => { |
| mkCanvas(); |
| for (let i = 0; i < 54; i++) { const a = Math.random() * Math.PI * 2, sp = 3 + Math.random() * 8; parts.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 4.5, g: 0.16 + Math.random() * 0.12, s: 5 + Math.random() * 7, rot: Math.random() * 6.28, vr: -0.3 + Math.random() * 0.6, col: COLORS[i % COLORS.length], life: 1 }); } |
| if (!raf) loop(); |
| }; |
| |
| const MIN_SPIN = 1100; // keep the wheel visibly spinning even when the draw is instant |
| const MIN_BACK = 600; // back-spin rewinds quicker than a fresh spin |
| const REEL = '<div class="reel-wrap"><div class="reel"><div class="reel-strip">🎮 🎨 🎙️ 🩺 💼 🎓 ⚙️ 🌍 💬 🛡️ 📸 💚 🎮 🎨 🎙️ 🩺<\/div><\/div><p class="reel-txt">spinning the wheel…<\/p><\/div>'; |
| const REEL_BACK = '<div class="reel-wrap"><div class="reel reel-back"><div class="reel-strip">🩺 🎙️ 🎨 🎮 💚 📸 🛡️ 💬 🌍 ⚙️ 🎓 💼 🩺 🎙️ 🎨 🎮<\/div><\/div><p class="reel-txt">↩ rewinding…<\/p><\/div>'; |
| // The reel is an OVERLAY on top of #card-out — never the card's own DOM. Two |
| // bugs this fixes: (1) mutating Gradio's Svelte-managed card node broke every |
| // spin after the first; (2) when a cached draw returns instantly the reel/ |
| // sound used to flash by invisibly — now the overlay (and ticks) stay up for |
| // at least MIN_SPIN before the reveal. |
| // Shared overlay+poll routine for both forward spins and back-spins. opts: |
| // { reel, sound, min, btn } — what to show, what to play, how long to hold, and |
| // which button glows. |
| RL._begin = (opts) => { |
| ac(); |
| RL._busy = true; // serialize: ignore clicks until land() |
| if (RL._poll) { clearInterval(RL._poll); RL._poll = null; } |
| if (RL._revealTimer) { clearTimeout(RL._revealTimer); RL._revealTimer = null; } |
| RL.stopSpinSound(); |
| RL._spinStart = performance.now(); |
| RL._min = opts.min; |
| // Poll/compare against any data-spin holder (real card OR a result placeholder). |
| const prev = document.querySelector('#card-out [data-spin]'); |
| RL._prevSpin = prev ? prev.getAttribute('data-spin') : '-1'; |
| const host = document.getElementById('card-out'); |
| if (host) { |
| host.style.position = 'relative'; |
| let ov = host.querySelector('.rl-reel-overlay'); |
| if (!ov) { ov = document.createElement('div'); ov.className = 'rl-reel-overlay'; host.appendChild(ov); } |
| ov.innerHTML = opts.reel; ov.style.display = 'flex'; |
| } |
| const b = document.getElementById(opts.btn); if (b) b.classList.add('spinning'); |
| opts.sound(); |
| // Poll for the new result (data-spin changes on every draw AND back-spin); |
| // reveal once it has arrived AND the wheel has spun for at least its minimum. |
| const t0 = performance.now(); |
| RL._poll = setInterval(() => { |
| const cur = document.querySelector('#card-out [data-spin]'); |
| const arrived = cur && cur.getAttribute('data-spin') !== RL._prevSpin; |
| if (arrived || performance.now() - t0 > 6000) { |
| clearInterval(RL._poll); RL._poll = null; |
| const wait = Math.max(0, RL._min - (performance.now() - RL._spinStart)); |
| RL._revealTimer = setTimeout(() => { RL._revealTimer = null; RL.land(); }, wait); |
| } |
| }, 40); |
| }; |
| RL.startSpin = () => RL._begin({ reel: REEL, sound: RL.startSpinSound, min: MIN_SPIN, btn: 'spin-btn' }); |
| RL.startBack = () => RL._begin({ reel: REEL_BACK, sound: RL.startBackSound, min: MIN_BACK, btn: 'back-btn' }); |
| RL.land = () => { |
| RL.stopSpinSound(); |
| ['spin-btn', 'back-btn'].forEach(id => { const b = document.getElementById(id); if (b) b.classList.remove('spinning'); }); |
| const host = document.getElementById('card-out'); |
| const ov = host && host.querySelector('.rl-reel-overlay'); |
| if (ov) ov.remove(); // reveal the result underneath |
| const card = document.querySelector('#card-out .space-card'); |
| if (card) { // celebrate real cards only (not placeholders) |
| card.style.animation = 'none'; void card.offsetWidth; card.style.animation = ''; |
| RL.chime(); |
| const r = card.getBoundingClientRect(); |
| RL.burst(r.left + r.width / 2, r.top + Math.min(120, r.height * 0.35)); |
| } |
| RL._busy = false; // ready for the next click |
| }; |
| |
| // delegated listeners survive Gradio re-renders. Capture phase so we can |
| // suppress Gradio's own click handler when a draw is mid-flight (prevents |
| // overlapping spin/back draws from desyncing the history state) or when Back |
| // is disabled (its click would otherwise no-op on the server). |
| document.addEventListener('click', (e) => { |
| const closest = (sel) => (e.target.closest && e.target.closest(sel)) || null; |
| const spin = closest('#spin-btn'); |
| const back = closest('#back-btn'); |
| if (spin || back) { |
| const backOff = back && (back.disabled || back.getAttribute('aria-disabled') === 'true'); |
| if (RL._busy || backOff) { e.preventDefault(); e.stopImmediatePropagation(); return; } |
| if (spin) RL.startSpin(); else RL.startBack(); |
| return; |
| } |
| const m = closest('#rl-mute'); |
| if (m) { RL.muted = !RL.muted; m.textContent = RL.muted ? '🔇' : '🔊'; m.classList.toggle('muted', RL.muted); if (RL.muted) RL.stopSpinSound(); } |
| }, true); |
| document.addEventListener('pointerover', (e) => { |
| if (e.target.closest && (e.target.closest('#spin-btn') || e.target.closest('#back-btn'))) RL.hover(); |
| }, true); |
| })(); |
| </script> |
| """ |
|
|
| |
|
|
| with gr.Blocks(css=CSS, title="🎰 Hackathon Roulette", theme=gr.themes.Base(), |
| head=HEAD_HTML) as demo: |
| |
| |
| |
| seen_st = gr.BrowserState([], storage_key="hr_seen_v1", secret="hr-roulette-v1") |
| cat_st = gr.State("All") |
| spin_ctr = gr.State(0) |
| hist_st = gr.State([]) |
| pos_st = gr.State(-1) |
|
|
| gr.HTML(f""" |
| <div class="app-header"> |
| <button id="rl-mute" class="rl-mute" title="Sound on / off" aria-label="Toggle sound">🔊</button> |
| <span class="hdr-icon">🎰</span> |
| <h1 class="hdr-title">Hackathon Roulette</h1> |
| <p class="hdr-sub">Spin to discover hidden gems from the <strong>{HACKATHON_NAME}</strong></p> |
| </div>""") |
|
|
| stats_out = gr.HTML() |
|
|
| with gr.Row(): |
| cat_dd = gr.Dropdown(choices=ALL_CATEGORIES, value="All", label="Browse by category", |
| elem_id="cat-dd", interactive=True, filterable=False) |
| with gr.Row(): |
| back_btn = gr.Button("↩ Back Spin", elem_id="back-btn", scale=2, |
| interactive=False, min_width=120) |
| spin_btn = gr.Button("🎰 SPIN", elem_id="spin-btn", scale=3, min_width=160) |
|
|
| card_out = gr.HTML(elem_id="card-out") |
|
|
| with gr.Accordion("📋 Browse all in this category", open=False, elem_id="browse-acc") as browse_acc: |
| browse_out = gr.HTML() |
|
|
| |
| demo.load(on_load, inputs=[seen_st], |
| outputs=[seen_st, cat_st, spin_ctr, hist_st, pos_st, stats_out, card_out, back_btn]) |
| spin_btn.click( |
| on_spin, |
| inputs=[seen_st, cat_st, spin_ctr, hist_st, pos_st], |
| outputs=[card_out, seen_st, spin_ctr, stats_out, hist_st, pos_st, back_btn], |
| ) |
| back_btn.click( |
| on_back, |
| inputs=[hist_st, pos_st, spin_ctr], |
| outputs=[card_out, pos_st, spin_ctr, back_btn], |
| ) |
| cat_dd.change(on_category, inputs=[cat_dd, seen_st], outputs=[cat_st, stats_out]) |
| |
| cat_dd.change(on_browse, inputs=[cat_dd, seen_st], outputs=[browse_out]) |
| browse_acc.expand(on_browse, inputs=[cat_st, seen_st], outputs=[browse_out]) |
|
|
|
|
| |
| try: |
| load_spaces() |
| except Exception as _exc: |
| print(f"[hackathon-roulette] prewarm skipped: {_exc}") |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|