Created
March 11, 2026 16:59
-
-
Save HoughIO/c279543387ff1bebf84ce46c3be529ee to your computer and use it in GitHub Desktop.
KittyCam Gacha UI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Kevin & Todd Cam - Gacha UI | |
| # Run on Pi AFTER upgrade-gacha.sh | |
| echo "π΄ Installing gacha UI..." | |
| sudo tee /opt/kittycam/web/trading-cards.html > /dev/null << 'HTMLEOF' | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Card Collection</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| :root,[data-theme="todd"]{ | |
| --bg:#0f0f1a;--surface:rgba(255,255,255,0.1);--border:rgba(255,255,255,0.2); | |
| --text:#fff;--text-dim:rgba(255,255,255,0.6);--accent:#ffd700 | |
| } | |
| [data-theme="kevin"]{ | |
| --bg:#fff8f0;--surface:rgba(255,140,50,0.15);--border:rgba(255,120,50,0.3); | |
| --text:#2a1810;--text-dim:rgba(42,24,16,0.7);--accent:#ff8c42 | |
| } | |
| body{min-height:100vh;background:linear-gradient(135deg,var(--bg) 0%,#1a1a2e 100%);font-family:-apple-system,system-ui,sans-serif;color:var(--text);padding-bottom:100px} | |
| .header{text-align:center;padding:2rem 1rem 1rem} | |
| .header h1{font-size:1.8rem;margin-bottom:.5rem} | |
| .progress{color:var(--text-dim);font-size:.9rem} | |
| .back-btn{display:inline-block;color:var(--accent);text-decoration:none;font-size:.85rem;margin-bottom:1rem;padding:.4rem .8rem;border:1px solid var(--border);border-radius:1.5rem} | |
| /* Pull Section */ | |
| .pull-section{text-align:center;padding:1.5rem;margin:0 1rem 1.5rem;background:var(--surface);border-radius:16px;border:1px solid var(--border)} | |
| .pull-btn{background:linear-gradient(135deg,var(--accent),#ff8c00);color:#000;border:none;padding:16px 40px;font-size:1.1rem;font-weight:bold;border-radius:30px;cursor:pointer;transition:all .2s;box-shadow:0 4px 20px rgba(255,215,0,0.3)} | |
| .pull-btn:hover{transform:scale(1.05);box-shadow:0 6px 30px rgba(255,215,0,0.5)} | |
| .pull-btn:disabled{background:#444;color:#888;cursor:not-allowed;box-shadow:none;transform:none} | |
| .pull-status{margin-top:12px;font-size:.85rem;color:var(--text-dim)} | |
| /* Card reveal overlay */ | |
| .reveal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:1000;display:none;align-items:center;justify-content:center;flex-direction:column} | |
| .reveal-overlay.show{display:flex} | |
| .reveal-card{width:280px;aspect-ratio:0.714;border-radius:12px;overflow:hidden;transform:scale(0) rotateY(180deg);transition:transform .6s cubic-bezier(0.34,1.56,0.64,1);box-shadow:0 0 60px rgba(255,215,0,0.5)} | |
| .reveal-card.show{transform:scale(1) rotateY(0deg)} | |
| .reveal-card img{width:100%;height:100%;object-fit:cover} | |
| .reveal-card.simba{box-shadow:0 0 80px rgba(255,215,0,0.8),0 0 120px rgba(255,140,0,0.5)} | |
| .reveal-text{margin-top:24px;font-size:1.5rem;font-weight:bold;opacity:0;transition:opacity .3s .4s} | |
| .reveal-text.show{opacity:1} | |
| .reveal-rarity{font-size:.9rem;color:var(--accent);margin-top:8px;opacity:0;transition:opacity .3s .5s} | |
| .reveal-rarity.show{opacity:1} | |
| .reveal-close{margin-top:30px;padding:12px 30px;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:20px;cursor:pointer;opacity:0;transition:opacity .3s .6s} | |
| .reveal-close.show{opacity:1} | |
| /* Card Grid */ | |
| .cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;padding:0 1rem} | |
| .card-item{aspect-ratio:0.714;border-radius:10px;overflow:hidden;position:relative;cursor:pointer;transition:transform .2s} | |
| .card-item:hover{transform:scale(1.03)} | |
| .card-item img{width:100%;height:100%;object-fit:cover} | |
| .card-item.locked{filter:brightness(0.15)} | |
| .card-item.locked::after{content:'?';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:3rem;color:rgba(255,255,255,0.3)} | |
| .card-label{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.9));padding:2rem .5rem .5rem;text-align:center} | |
| .card-name{font-size:.75rem;font-weight:bold;color:#fff} | |
| .card-theme{font-size:.6rem;color:rgba(255,255,255,0.6);text-transform:uppercase} | |
| .card-item.simba .card-name{color:#ffd700} | |
| /* Filters */ | |
| .filters{display:flex;justify-content:center;gap:.5rem;margin:1rem;flex-wrap:wrap} | |
| .filter-btn{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.4rem 1rem;border-radius:1.5rem;cursor:pointer;font-size:.8rem} | |
| .filter-btn.active{background:linear-gradient(135deg,var(--accent),#ff8c00);color:#000;border-color:transparent} | |
| /* Bottom nav */ | |
| .tabs{position:fixed;bottom:0;left:0;right:0;display:flex;background:var(--bg);border-top:1px solid var(--border);padding-bottom:env(safe-area-inset-bottom)} | |
| .tab{flex:1;padding:12px;text-align:center;color:var(--text-dim);font-size:.7rem;text-decoration:none} | |
| .tab.active{color:var(--accent)} | |
| .tab-icon{font-size:1.3rem;display:block;margin-bottom:3px} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <a href="index.html" class="back-btn">β Back to Cam</a> | |
| <h1>π΄ Card Collection</h1> | |
| <div class="progress" id="progress">Loading...</div> | |
| </div> | |
| <div class="pull-section" id="pull-section"> | |
| <button class="pull-btn" id="pull-btn" disabled>Loading...</button> | |
| <div class="pull-status" id="pull-status"></div> | |
| </div> | |
| <div class="filters"> | |
| <button class="filter-btn active" data-filter="all">All</button> | |
| <button class="filter-btn" data-filter="collected">Collected</button> | |
| <button class="filter-btn" data-filter="kevin">Kevin</button> | |
| <button class="filter-btn" data-filter="todd">Todd</button> | |
| <button class="filter-btn" data-filter="simba">Simba β</button> | |
| </div> | |
| <div class="cards-grid" id="cards-grid"></div> | |
| <div class="reveal-overlay" id="reveal-overlay"> | |
| <div class="reveal-card" id="reveal-card"> | |
| <img id="reveal-img" src="" alt=""> | |
| </div> | |
| <div class="reveal-text" id="reveal-text"></div> | |
| <div class="reveal-rarity" id="reveal-rarity"></div> | |
| <button class="reveal-close" id="reveal-close">Awesome!</button> | |
| </div> | |
| <nav class="tabs"> | |
| <a href="index.html" class="tab"><span class="tab-icon">πΊ</span>Live</a> | |
| <a href="index.html#clips" class="tab"><span class="tab-icon">π¬</span>Clips</a> | |
| <a href="trading-cards.html" class="tab active"><span class="tab-icon">π΄</span>Cards</a> | |
| <a href="index.html#activity" class="tab"><span class="tab-icon">π</span>Activity</a> | |
| </nav> | |
| <script> | |
| // Load theme | |
| const theme = localStorage.getItem('kittycam_theme') || 'todd'; | |
| document.documentElement.setAttribute('data-theme', theme); | |
| // All possible cards | |
| const ALL_CARDS = [ | |
| {pet:'kevin',theme:'samurai'},{pet:'kevin',theme:'space-captain'},{pet:'kevin',theme:'vampire'}, | |
| {pet:'kevin',theme:'chef'},{pet:'kevin',theme:'disco'},{pet:'kevin',theme:'sopranos'},{pet:'kevin',theme:'classical'}, | |
| {pet:'todd',theme:'samurai'},{pet:'todd',theme:'space-captain'},{pet:'todd',theme:'vampire'}, | |
| {pet:'todd',theme:'chef'},{pet:'todd',theme:'disco'},{pet:'todd',theme:'sopranos'},{pet:'todd',theme:'classical'}, | |
| {pet:'simba',theme:'samurai'},{pet:'simba',theme:'space-captain'},{pet:'simba',theme:'vampire'}, | |
| {pet:'simba',theme:'chef'},{pet:'simba',theme:'disco'},{pet:'simba',theme:'sopranos'},{pet:'simba',theme:'classical'}, | |
| ]; | |
| let collectedSet = new Set(); | |
| let canPull = false; | |
| let currentFilter = 'all'; | |
| function cardKey(c) { return c.pet + '-' + c.theme; } | |
| function cardImage(c) { return 'trading-cards/batch-001/' + c.pet + '-' + c.theme + '.png'; } | |
| function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } | |
| async function loadState() { | |
| try { | |
| const res = await fetch('/api/gacha/state'); | |
| const data = await res.json(); | |
| collectedSet = new Set(data.collected.map(c => c[0] + '-' + c[1])); | |
| canPull = data.canPull; | |
| updateUI(); | |
| } catch(e) { | |
| console.error('Failed to load state:', e); | |
| document.getElementById('pull-status').textContent = 'Error loading state'; | |
| } | |
| } | |
| function updateUI() { | |
| // Progress | |
| document.getElementById('progress').textContent = | |
| collectedSet.size + ' / ' + ALL_CARDS.length + ' cards collected'; | |
| // Pull button | |
| const btn = document.getElementById('pull-btn'); | |
| const status = document.getElementById('pull-status'); | |
| if (collectedSet.size >= ALL_CARDS.length) { | |
| btn.textContent = 'π Complete!'; | |
| btn.disabled = true; | |
| status.textContent = 'You collected them all!'; | |
| } else if (canPull) { | |
| btn.textContent = 'β¨ Open Daily Card'; | |
| btn.disabled = false; | |
| status.textContent = 'Your daily card is ready!'; | |
| } else { | |
| btn.textContent = 'π Come Back Tomorrow'; | |
| btn.disabled = true; | |
| status.textContent = 'Next card available tomorrow'; | |
| } | |
| renderCards(); | |
| } | |
| function renderCards() { | |
| const grid = document.getElementById('cards-grid'); | |
| let cards = ALL_CARDS; | |
| if (currentFilter === 'collected') { | |
| cards = cards.filter(c => collectedSet.has(cardKey(c))); | |
| } else if (currentFilter !== 'all') { | |
| cards = cards.filter(c => c.pet === currentFilter); | |
| } | |
| grid.innerHTML = cards.map(c => { | |
| const key = cardKey(c); | |
| const collected = collectedSet.has(key); | |
| const isSimba = c.pet === 'simba'; | |
| return ` | |
| <div class="card-item ${collected ? '' : 'locked'} ${isSimba ? 'simba' : ''}" | |
| data-key="${key}" onclick="${collected ? `viewCard('${c.pet}','${c.theme}')` : ''}"> | |
| <img src="${cardImage(c)}" alt="${c.pet} ${c.theme}" loading="lazy"> | |
| ${collected ? ` | |
| <div class="card-label"> | |
| <div class="card-name">${capitalize(c.pet)}</div> | |
| <div class="card-theme">${c.theme}</div> | |
| </div> | |
| ` : ''} | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| async function doPull() { | |
| const btn = document.getElementById('pull-btn'); | |
| btn.disabled = true; | |
| btn.textContent = 'β¨ Opening...'; | |
| try { | |
| const res = await fetch('/api/gacha/pull', { method: 'POST' }); | |
| const data = await res.json(); | |
| if (data.error) { | |
| alert(data.error); | |
| loadState(); | |
| return; | |
| } | |
| // Show reveal | |
| const card = data.card; | |
| collectedSet.add(cardKey(card)); | |
| canPull = false; | |
| showReveal(card, data.isSimba); | |
| } catch(e) { | |
| console.error('Pull failed:', e); | |
| alert('Failed to pull card'); | |
| loadState(); | |
| } | |
| } | |
| function showReveal(card, isSimba) { | |
| const overlay = document.getElementById('reveal-overlay'); | |
| const cardEl = document.getElementById('reveal-card'); | |
| const img = document.getElementById('reveal-img'); | |
| const text = document.getElementById('reveal-text'); | |
| const rarity = document.getElementById('reveal-rarity'); | |
| const closeBtn = document.getElementById('reveal-close'); | |
| img.src = cardImage(card); | |
| text.textContent = capitalize(card.pet) + ' - ' + capitalize(card.theme); | |
| if (isSimba) { | |
| cardEl.classList.add('simba'); | |
| rarity.textContent = 'β ULTRA RARE! β'; | |
| } else { | |
| cardEl.classList.remove('simba'); | |
| rarity.textContent = ''; | |
| } | |
| overlay.classList.add('show'); | |
| setTimeout(() => { | |
| cardEl.classList.add('show'); | |
| text.classList.add('show'); | |
| rarity.classList.add('show'); | |
| closeBtn.classList.add('show'); | |
| }, 100); | |
| } | |
| function closeReveal() { | |
| const overlay = document.getElementById('reveal-overlay'); | |
| const cardEl = document.getElementById('reveal-card'); | |
| const text = document.getElementById('reveal-text'); | |
| const rarity = document.getElementById('reveal-rarity'); | |
| const closeBtn = document.getElementById('reveal-close'); | |
| cardEl.classList.remove('show'); | |
| text.classList.remove('show'); | |
| rarity.classList.remove('show'); | |
| closeBtn.classList.remove('show'); | |
| setTimeout(() => { | |
| overlay.classList.remove('show'); | |
| updateUI(); | |
| }, 300); | |
| } | |
| function viewCard(pet, theme) { | |
| // Could open a detail view, for now just a simple alert or nothing | |
| } | |
| // Event listeners | |
| document.getElementById('pull-btn').onclick = doPull; | |
| document.getElementById('reveal-close').onclick = closeReveal; | |
| document.getElementById('reveal-overlay').onclick = (e) => { | |
| if (e.target.id === 'reveal-overlay') closeReveal(); | |
| }; | |
| document.querySelectorAll('.filter-btn').forEach(btn => { | |
| btn.onclick = () => { | |
| document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| currentFilter = btn.dataset.filter; | |
| renderCards(); | |
| }; | |
| }); | |
| // Init | |
| loadState(); | |
| </script> | |
| </body> | |
| </html> | |
| HTMLEOF | |
| echo "β Gacha UI installed!" | |
| echo "" | |
| echo "Refresh the trading cards page to see the gacha system!" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment