Skip to content

Instantly share code, notes, and snippets.

@HoughIO
Created March 11, 2026 16:59
Show Gist options
  • Select an option

  • Save HoughIO/c279543387ff1bebf84ce46c3be529ee to your computer and use it in GitHub Desktop.

Select an option

Save HoughIO/c279543387ff1bebf84ce46c3be529ee to your computer and use it in GitHub Desktop.
KittyCam Gacha UI
#!/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