diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..a43399a --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --production +COPY server.js ./ +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..b4ad166 --- /dev/null +++ b/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "tamagotchi-api", + "version": "1.0.0", + "description": "Tamagotchi as a Service โ€” Backend API with Prometheus metrics", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.21.0", + "pg": "^8.13.0", + "prom-client": "^15.1.0", + "cors": "^2.8.5", + "uuid": "^10.0.0" + } +} diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..22c0ad7 --- /dev/null +++ b/api/server.js @@ -0,0 +1,368 @@ +const express = require('express'); +const cors = require('cors'); +const { Pool } = require('pg'); +const { v4: uuidv4 } = require('uuid'); +const client = require('prom-client'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// ============================================================================ +// Prometheus Metrics Setup +// ============================================================================ +const register = new client.Registry(); +client.collectDefaultMetrics({ register }); + +// Custom Tamagotchi metrics +const hungerGauge = new client.Gauge({ + name: 'tamagotchi_hunger_level', + help: 'Current hunger level of each creature (0=full, 100=starving)', + labelNames: ['creature_id', 'creature_name', 'creature_type'], + registers: [register], +}); + +const happinessGauge = new client.Gauge({ + name: 'tamagotchi_happiness_score', + help: 'Current happiness score of each creature (0=miserable, 100=ecstatic)', + labelNames: ['creature_id', 'creature_name', 'creature_type'], + registers: [register], +}); + +const energyGauge = new client.Gauge({ + name: 'tamagotchi_energy_level', + help: 'Current energy level of each creature (0=exhausted, 100=hyperactive)', + labelNames: ['creature_id', 'creature_name', 'creature_type'], + registers: [register], +}); + +const aliveGauge = new client.Gauge({ + name: 'tamagotchi_creatures_alive_total', + help: 'Total number of creatures currently alive', + registers: [register], +}); + +const deadGauge = new client.Gauge({ + name: 'tamagotchi_creatures_dead_total', + help: 'Total number of creatures that have died', + registers: [register], +}); + +const feedCounter = new client.Counter({ + name: 'tamagotchi_feed_actions_total', + help: 'Total number of feed actions performed', + labelNames: ['creature_type'], + registers: [register], +}); + +const playCounter = new client.Counter({ + name: 'tamagotchi_play_actions_total', + help: 'Total number of play actions performed', + labelNames: ['creature_type'], + registers: [register], +}); + +const sleepCounter = new client.Counter({ + name: 'tamagotchi_sleep_actions_total', + help: 'Total number of sleep actions performed', + labelNames: ['creature_type'], + registers: [register], +}); + +const httpRequestDuration = new client.Histogram({ + name: 'tamagotchi_http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 5], + registers: [register], +}); + +const httpRequestsTotal = new client.Counter({ + name: 'tamagotchi_http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], + registers: [register], +}); + +// HTTP metrics middleware +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = (Date.now() - start) / 1000; + const route = req.route ? req.route.path : req.path; + httpRequestDuration.observe({ method: req.method, route, status_code: res.statusCode }, duration); + httpRequestsTotal.inc({ method: req.method, route, status_code: res.statusCode }); + }); + next(); +}); + +// ============================================================================ +// Database Setup +// ============================================================================ +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'tamagotchi', + user: process.env.DB_USER || 'tamagotchi', + password: process.env.DB_PASSWORD || 'tamagotchi123', +}); + +async function initDB() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS creatures ( + id UUID PRIMARY KEY, + name VARCHAR(50) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'egg', + hunger FLOAT NOT NULL DEFAULT 50, + happiness FLOAT NOT NULL DEFAULT 70, + energy FLOAT NOT NULL DEFAULT 80, + is_alive BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + last_interaction TIMESTAMP DEFAULT NOW() + ) + `); + + // Seed some default creatures if table is empty + const { rows } = await client.query('SELECT COUNT(*) FROM creatures'); + if (parseInt(rows[0].count) === 0) { + const seedCreatures = [ + { name: 'Pixel', type: 'dragon' }, + { name: 'Nimbus', type: 'cat' }, + { name: 'Sprocket', type: 'robot' }, + { name: 'Blossom', type: 'plant' }, + { name: 'Byte', type: 'alien' }, + ]; + for (const c of seedCreatures) { + await client.query( + 'INSERT INTO creatures (id, name, type, hunger, happiness, energy) VALUES ($1, $2, $3, $4, $5, $6)', + [uuidv4(), c.name, c.type, 30 + Math.random() * 40, 50 + Math.random() * 40, 60 + Math.random() * 30] + ); + } + console.log('๐Ÿฃ Seeded 5 default creatures'); + } + } finally { + client.release(); + } +} + +// ============================================================================ +// Creature Decay Loop โ€” Stats decrease over time! +// ============================================================================ +const DECAY_INTERVAL_MS = parseInt(process.env.DECAY_INTERVAL_MS || '15000'); // every 15 seconds +const HUNGER_DECAY = parseFloat(process.env.HUNGER_DECAY || '2'); // hunger increases by 2 +const HAPPINESS_DECAY = parseFloat(process.env.HAPPINESS_DECAY || '1.5'); // happiness decreases by 1.5 +const ENERGY_DECAY = parseFloat(process.env.ENERGY_DECAY || '1'); // energy decreases by 1 + +setInterval(async () => { + try { + const client = await pool.connect(); + try { + // Increase hunger, decrease happiness and energy for alive creatures + await client.query(` + UPDATE creatures + SET + hunger = LEAST(100, hunger + $1), + happiness = GREATEST(0, happiness - $2), + energy = GREATEST(0, energy - $3), + is_alive = CASE + WHEN hunger >= 100 AND happiness <= 0 THEN false + ELSE is_alive + END + WHERE is_alive = true + `, [HUNGER_DECAY, HAPPINESS_DECAY, ENERGY_DECAY]); + + // Update Prometheus metrics + const { rows } = await client.query('SELECT * FROM creatures'); + let alive = 0, dead = 0; + for (const c of rows) { + const labels = { creature_id: c.id, creature_name: c.name, creature_type: c.type }; + hungerGauge.set(labels, c.hunger); + happinessGauge.set(labels, c.happiness); + energyGauge.set(labels, c.energy); + if (c.is_alive) alive++; else dead++; + } + aliveGauge.set(alive); + deadGauge.set(dead); + } finally { + client.release(); + } + } catch (err) { + console.error('Decay loop error:', err.message); + } +}, DECAY_INTERVAL_MS); + +// ============================================================================ +// API Routes +// ============================================================================ + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Prometheus metrics endpoint +app.get('/metrics', async (req, res) => { + try { + res.set('Content-Type', register.contentType); + res.end(await register.metrics()); + } catch (err) { + res.status(500).end(err.message); + } +}); + +// GET /creatures โ€” List all creatures +app.get('/api/creatures', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM creatures ORDER BY created_at ASC'); + res.json(rows); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /creatures/:id โ€” Get a specific creature +app.get('/api/creatures/:id', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' }); + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /creatures โ€” Adopt a new creature +app.post('/api/creatures', async (req, res) => { + try { + const { name, type } = req.body; + if (!name || !type) return res.status(400).json({ error: 'name and type are required' }); + const allowedTypes = ['dragon', 'cat', 'robot', 'plant', 'alien']; + if (!allowedTypes.includes(type)) { + return res.status(400).json({ error: `type must be one of: ${allowedTypes.join(', ')}` }); + } + const id = uuidv4(); + await pool.query( + 'INSERT INTO creatures (id, name, type) VALUES ($1, $2, $3)', + [id, name, type] + ); + const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [id]); + res.status(201).json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /creatures/:id/feed โ€” Feed a creature +app.post('/api/creatures/:id/feed', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' }); + if (!rows[0].is_alive) return res.status(400).json({ error: 'Cannot feed a dead creature ๐Ÿ’€' }); + + await pool.query( + 'UPDATE creatures SET hunger = GREATEST(0, hunger - 25), happiness = LEAST(100, happiness + 5), last_interaction = NOW() WHERE id = $1', + [req.params.id] + ); + feedCounter.inc({ creature_type: rows[0].type }); + const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + res.json({ message: `${result.rows[0].name} has been fed! ๐Ÿ–`, creature: result.rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /creatures/:id/play โ€” Play with a creature +app.post('/api/creatures/:id/play', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' }); + if (!rows[0].is_alive) return res.status(400).json({ error: 'Cannot play with a dead creature ๐Ÿ’€' }); + + await pool.query( + 'UPDATE creatures SET happiness = LEAST(100, happiness + 20), energy = GREATEST(0, energy - 10), hunger = LEAST(100, hunger + 5), last_interaction = NOW() WHERE id = $1', + [req.params.id] + ); + playCounter.inc({ creature_type: rows[0].type }); + const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + res.json({ message: `${result.rows[0].name} had a great time! ๐ŸŽฎ`, creature: result.rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /creatures/:id/sleep โ€” Put a creature to sleep +app.post('/api/creatures/:id/sleep', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' }); + if (!rows[0].is_alive) return res.status(400).json({ error: 'This creature is already in eternal sleep ๐Ÿ’€' }); + + await pool.query( + 'UPDATE creatures SET energy = LEAST(100, energy + 30), hunger = LEAST(100, hunger + 5), last_interaction = NOW() WHERE id = $1', + [req.params.id] + ); + sleepCounter.inc({ creature_type: rows[0].type }); + const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + res.json({ message: `${result.rows[0].name} is well rested! ๐Ÿ’ค`, creature: result.rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /creatures/:id/revive โ€” Revive a dead creature (admin only concept) +app.post('/api/creatures/:id/revive', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Creature not found' }); + if (rows[0].is_alive) return res.status(400).json({ error: 'This creature is already alive!' }); + + await pool.query( + 'UPDATE creatures SET is_alive = true, hunger = 50, happiness = 50, energy = 50, last_interaction = NOW() WHERE id = $1', + [req.params.id] + ); + const result = await pool.query('SELECT * FROM creatures WHERE id = $1', [req.params.id]); + res.json({ message: `${result.rows[0].name} has been revived! โœจ`, creature: result.rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /stats โ€” Global statistics +app.get('/api/stats', async (req, res) => { + try { + const { rows } = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE is_alive = true) as alive_count, + COUNT(*) FILTER (WHERE is_alive = false) as dead_count, + ROUND(AVG(hunger)::numeric, 1) FILTER (WHERE is_alive = true) as avg_hunger, + ROUND(AVG(happiness)::numeric, 1) FILTER (WHERE is_alive = true) as avg_happiness, + ROUND(AVG(energy)::numeric, 1) FILTER (WHERE is_alive = true) as avg_energy, + COUNT(*) FILTER (WHERE hunger > 80 AND is_alive = true) as starving_count, + COUNT(*) FILTER (WHERE happiness < 20 AND is_alive = true) as sad_count + FROM creatures + `); + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ============================================================================ +// Start Server +// ============================================================================ +const PORT = parseInt(process.env.PORT || '8080'); + +initDB() + .then(() => { + app.listen(PORT, '0.0.0.0', () => { + console.log(`๐Ÿฃ Tamagotchi API running on port ${PORT}`); + console.log(`๐Ÿ“Š Metrics available at /metrics`); + console.log(`โฑ๏ธ Decay interval: ${DECAY_INTERVAL_MS}ms`); + }); + }) + .catch((err) => { + console.error('Failed to initialize database:', err); + process.exit(1); + }); diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1669669 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY app.js /usr/share/nginx/html/ +EXPOSE 80 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..c00dd36 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,295 @@ +// ============================================================================ +// Tamagotchi as a Service โ€” Frontend Application +// ============================================================================ + +const API_BASE = window.TAMAGOTCHI_API_URL || '/api'; +const REFRESH_INTERVAL = 5000; // Refresh creature stats every 5 seconds + +// Creature type emoji map +const TYPE_EMOJI = { + dragon: '๐Ÿ‰', + cat: '๐Ÿฑ', + robot: '๐Ÿค–', + plant: '๐ŸŒฑ', + alien: '๐Ÿ‘ฝ', +}; + +// i18n translations +const i18n = { + en: { + adopt_title: 'Adopt a Creature', + name_label: 'Name', + type_label: 'Type', + cancel: 'Cancel', + adopt_btn: 'Adopt! ๐ŸŽ‰', + hunger: 'Hunger', + happiness: 'Happiness', + energy: 'Energy', + feed: '๐Ÿ– Feed', + play: '๐ŸŽฎ Play', + sleep: '๐Ÿ’ค Sleep', + revive: 'โœจ Revive', + alive: 'Alive', + dead: 'Dead', + empty_title: 'No creatures yet!', + empty_subtitle: 'Click the + button to adopt your first creature.', + }, + fr: { + adopt_title: 'Adopter une Crรฉature', + name_label: 'Nom', + type_label: 'Type', + cancel: 'Annuler', + adopt_btn: 'Adopter ! ๐ŸŽ‰', + hunger: 'Faim', + happiness: 'Bonheur', + energy: 'ร‰nergie', + feed: '๐Ÿ– Nourrir', + play: '๐ŸŽฎ Jouer', + sleep: '๐Ÿ’ค Dormir', + revive: 'โœจ Ressusciter', + alive: 'Vivant', + dead: 'Mort', + empty_title: 'Pas encore de crรฉatures !', + empty_subtitle: 'Cliquez sur le bouton + pour adopter votre premiรจre crรฉature.', + }, +}; + +let currentLang = 'en'; +let creatures = []; + +// ---- i18n ---- +function t(key) { + return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key; +} + +function setLang(lang) { + currentLang = lang; + document.querySelectorAll('.lang-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.lang === lang); + }); + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.dataset.i18n); + }); + renderCreatures(); +} + +// ---- API Calls ---- +async function fetchCreatures() { + try { + const res = await fetch(`${API_BASE}/creatures`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + creatures = await res.json(); + renderCreatures(); + updateGlobalStats(); + } catch (err) { + console.error('Failed to fetch creatures:', err); + } +} + +async function fetchGlobalStats() { + try { + const res = await fetch(`${API_BASE}/stats`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const stats = await res.json(); + document.getElementById('aliveCount').textContent = stats.alive_count || 0; + document.getElementById('deadCount').textContent = stats.dead_count || 0; + document.getElementById('starvingCount').textContent = stats.starving_count || 0; + } catch (err) { + console.error('Failed to fetch stats:', err); + } +} + +function updateGlobalStats() { + const alive = creatures.filter(c => c.is_alive).length; + const dead = creatures.filter(c => !c.is_alive).length; + const starving = creatures.filter(c => c.is_alive && c.hunger > 80).length; + document.getElementById('aliveCount').textContent = alive; + document.getElementById('deadCount').textContent = dead; + document.getElementById('starvingCount').textContent = starving; +} + +async function adoptCreature(name, type) { + try { + const res = await fetch(`${API_BASE}/creatures`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const creature = await res.json(); + showToast(`${creature.name} ${TYPE_EMOJI[creature.type]} has been adopted!`); + await fetchCreatures(); + } catch (err) { + showToast(`Failed to adopt creature: ${err.message}`); + } +} + +async function creatureAction(id, action) { + try { + const res = await fetch(`${API_BASE}/creatures/${id}/${action}`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json(); + showToast(data.error || `Action failed`); + return; + } + const data = await res.json(); + showToast(data.message); + await fetchCreatures(); + } catch (err) { + showToast(`Action failed: ${err.message}`); + } +} + +// ---- Rendering ---- +function getStatColor(stat, value) { + if (stat === 'hunger') { + if (value > 80) return 'var(--accent-red)'; + if (value > 50) return 'var(--accent-orange)'; + return 'var(--accent-green)'; + } + if (value < 20) return 'var(--accent-red)'; + if (value < 50) return 'var(--accent-yellow)'; + return stat === 'happiness' ? 'var(--accent-purple)' : 'var(--accent-cyan)'; +} + +function renderCreatures() { + const grid = document.getElementById('creaturesGrid'); + + if (creatures.length === 0) { + grid.innerHTML = ` +
+
๐Ÿฅš
+

${t('empty_title')}

+

${t('empty_subtitle')}

+
+ `; + return; + } + + grid.innerHTML = creatures.map(c => { + const emoji = TYPE_EMOJI[c.type] || 'โ“'; + const statusClass = c.is_alive ? 'alive' : 'dead'; + const statusText = c.is_alive ? t('alive') : t('dead'); + + const hungerDisplay = c.is_alive ? Math.round(c.hunger) : 0; + const happinessDisplay = c.is_alive ? Math.round(c.happiness) : 0; + const energyDisplay = c.is_alive ? Math.round(c.energy) : 0; + + return ` +
+
+
+
${c.is_alive ? emoji : '๐Ÿ’€'}
+
+

${escapeHtml(c.name)}

+ ${escapeHtml(c.type)} +
+
+ ${statusText} +
+ +
+
+
+ ๐Ÿ– ${t('hunger')} + ${hungerDisplay}% +
+
+
+
+
+
+
+ ๐Ÿ˜Š ${t('happiness')} + ${happinessDisplay}% +
+
+
+
+
+
+
+ โšก ${t('energy')} + ${energyDisplay}% +
+
+
+
+
+
+ +
+ ${c.is_alive ? ` + + + + ` : ` + + `} +
+
+ `; + }).join(''); +} + +// ---- Utilities ---- +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showToast(message) { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.remove(), 3000); +} + +// ---- Modal ---- +const adoptModal = document.getElementById('adoptModal'); +const adoptForm = document.getElementById('adoptForm'); +let selectedType = 'dragon'; + +document.getElementById('adoptBtn').addEventListener('click', () => { + adoptModal.classList.add('active'); +}); + +document.getElementById('cancelAdopt').addEventListener('click', () => { + adoptModal.classList.remove('active'); +}); + +adoptModal.addEventListener('click', (e) => { + if (e.target === adoptModal) adoptModal.classList.remove('active'); +}); + +document.querySelectorAll('.type-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + selectedType = btn.dataset.type; + }); +}); + +adoptForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = document.getElementById('creatureName').value.trim(); + if (!name) return; + await adoptCreature(name, selectedType); + adoptModal.classList.remove('active'); + adoptForm.reset(); + document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('selected')); + document.querySelector('.type-btn[data-type="dragon"]').classList.add('selected'); + selectedType = 'dragon'; +}); + +// ---- Language ---- +document.querySelectorAll('.lang-btn').forEach(btn => { + btn.addEventListener('click', () => setLang(btn.dataset.lang)); +}); + +// ---- Init ---- +fetchCreatures(); +setInterval(fetchCreatures, REFRESH_INTERVAL); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3229a6a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,74 @@ + + + + + + ๐Ÿฃ Tamagotchi as a Service + + + + + + +
+ +
+ +
+
- Alive
+
- Dead
+
- Starving
+
+
+ + +
+
+ + +
+ +
+ + + + + + + + +
+
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8bfb146 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://tamagotchi-api:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..a8ffe53 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,489 @@ +/* ============================================================================ + Tamagotchi as a Service โ€” Stylesheet + Premium dark theme with neon accents and glassmorphism + ============================================================================ */ + +:root { + --bg-primary: #0a0a1a; + --bg-card: rgba(20, 20, 45, 0.7); + --bg-card-hover: rgba(30, 30, 60, 0.85); + --glass-border: rgba(255, 255, 255, 0.08); + --text-primary: #e8e8f0; + --text-secondary: #8888aa; + --text-muted: #555577; + --accent-cyan: #00e5ff; + --accent-magenta: #ff00e5; + --accent-purple: #a855f7; + --accent-green: #00ff88; + --accent-red: #ff4466; + --accent-yellow: #ffbb00; + --accent-orange: #ff8800; + --gradient-primary: linear-gradient(135deg, #a855f7 0%, #00e5ff 100%); + --gradient-danger: linear-gradient(135deg, #ff4466 0%, #ff8800 100%); + --shadow-glow: 0 0 20px rgba(168, 85, 247, 0.3); + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 24px; + --font: 'Space Grotesk', system-ui, sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(ellipse at 20% 50%, rgba(168, 85, 247, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(0, 229, 255, 0.06) 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, rgba(255, 0, 229, 0.05) 0%, transparent 50%); + z-index: -1; + animation: bgShift 20s ease-in-out infinite alternate; +} + +@keyframes bgShift { + 0% { transform: translate(0, 0) rotate(0deg); } + 100% { transform: translate(-5%, 3%) rotate(3deg); } +} + +/* ---- Header ---- */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.2rem 2rem; + backdrop-filter: blur(20px); + background: rgba(10, 10, 26, 0.8); + border-bottom: 1px solid var(--glass-border); + position: sticky; + top: 0; + z-index: 100; +} + +.header__logo { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.header__emoji { + font-size: 2rem; + animation: bounce 2s ease-in-out infinite; +} + +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +.header__logo h1 { + font-size: 1.3rem; + font-weight: 700; + letter-spacing: -0.5px; +} + +.header__accent { + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.header__stats { + display: flex; + gap: 0.5rem; +} + +.stat-chip { + padding: 0.35rem 0.8rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} +.stat-chip.alive { background: rgba(0, 255, 136, 0.1); color: var(--accent-green); border-color: rgba(0, 255, 136, 0.2); } +.stat-chip.dead { background: rgba(255, 68, 102, 0.1); color: var(--accent-red); border-color: rgba(255, 68, 102, 0.2); } +.stat-chip.starving { background: rgba(255, 187, 0, 0.1); color: var(--accent-yellow); border-color: rgba(255, 187, 0, 0.2); } + +.header__lang { display: flex; gap: 0.3rem; } +.lang-btn { + padding: 0.3rem 0.6rem; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--glass-border); + cursor: pointer; + font-family: var(--font); + font-size: 0.75rem; + font-weight: 600; + transition: all 0.2s; +} +.lang-btn.active, .lang-btn:hover { + background: var(--accent-purple); + color: white; + border-color: var(--accent-purple); +} + +/* ---- Creatures Grid ---- */ +.creatures-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +/* ---- Creature Card ---- */ +.creature-card { + background: var(--bg-card); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + backdrop-filter: blur(20px); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.creature-card:hover { + background: var(--bg-card-hover); + transform: translateY(-4px); + box-shadow: var(--shadow-glow); + border-color: rgba(168, 85, 247, 0.3); +} + +.creature-card.dead { + opacity: 0.5; + filter: grayscale(0.6); +} + +.creature-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.creature-card__avatar { + font-size: 3rem; + width: 70px; + height: 70px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(168, 85, 247, 0.1); + border: 2px solid rgba(168, 85, 247, 0.2); + animation: pulse 3s ease-in-out infinite; +} + +.creature-card.dead .creature-card__avatar { + animation: none; + border-color: rgba(255, 68, 102, 0.3); + background: rgba(255, 68, 102, 0.05); +} + +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.2); } + 50% { box-shadow: 0 0 20px 5px rgba(168, 85, 247, 0.15); } +} + +.creature-card__info h3 { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.2rem; +} + +.creature-card__type { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 1px; +} + +.creature-card__status { + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; +} +.creature-card__status.alive { background: rgba(0, 255, 136, 0.15); color: var(--accent-green); } +.creature-card__status.dead { background: rgba(255, 68, 102, 0.15); color: var(--accent-red); } + +/* ---- Stats Bars ---- */ +.creature-card__stats { margin: 1rem 0; } + +.stat-bar { + margin-bottom: 0.7rem; +} + +.stat-bar__label { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.3rem; +} + +.stat-bar__track { + height: 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + overflow: hidden; +} + +.stat-bar__fill { + height: 100%; + border-radius: 999px; + transition: width 0.8s ease, background 0.5s ease; +} + +.stat-bar__fill.hunger { background: var(--gradient-danger); } +.stat-bar__fill.happiness { background: var(--gradient-primary); } +.stat-bar__fill.energy { background: linear-gradient(135deg, #00ff88 0%, #00e5ff 100%); } + +/* ---- Action Buttons ---- */ +.creature-card__actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +.action-btn { + flex: 1; + padding: 0.6rem 0.5rem; + border-radius: var(--radius-md); + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-primary); + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; + font-weight: 600; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; +} + +.action-btn:hover { + background: rgba(168, 85, 247, 0.15); + border-color: rgba(168, 85, 247, 0.3); + transform: scale(1.03); +} + +.action-btn:active { + transform: scale(0.97); +} + +.action-btn.revive { + background: rgba(0, 255, 136, 0.1); + border-color: rgba(0, 255, 136, 0.2); + color: var(--accent-green); +} + +/* ---- FAB ---- */ +.fab { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 60px; + height: 60px; + border-radius: 50%; + background: var(--gradient-primary); + border: none; + color: white; + font-size: 2rem; + cursor: pointer; + box-shadow: 0 4px 20px rgba(168, 85, 247, 0.4); + transition: all 0.3s; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.fab:hover { + transform: scale(1.1) rotate(90deg); + box-shadow: 0 6px 30px rgba(168, 85, 247, 0.6); +} + +/* ---- Modal ---- */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: none; + align-items: center; + justify-content: center; + z-index: 200; +} + +.modal-overlay.active { display: flex; } + +.modal { + background: var(--bg-card); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: 2rem; + width: 90%; + max-width: 480px; + backdrop-filter: blur(30px); + animation: modalIn 0.3s ease; +} + +@keyframes modalIn { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.modal__title { + font-size: 1.3rem; + margin-bottom: 1.5rem; + text-align: center; +} + +.form-group { + margin-bottom: 1.2rem; +} + +.form-group label { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.form-group input { + width: 100%; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-primary); + font-family: var(--font); + font-size: 0.95rem; + outline: none; + transition: border-color 0.2s; +} + +.form-group input:focus { + border-color: var(--accent-purple); + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.15); +} + +.type-selector { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.type-btn { + padding: 0.5rem 0.8rem; + border-radius: var(--radius-md); + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-primary); + cursor: pointer; + font-family: var(--font); + font-size: 0.85rem; + transition: all 0.2s; +} + +.type-btn.selected, .type-btn:hover { + background: rgba(168, 85, 247, 0.15); + border-color: var(--accent-purple); +} + +.modal__actions { + display: flex; + gap: 0.8rem; + margin-top: 1.5rem; +} + +.btn { + flex: 1; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + font-family: var(--font); + font-size: 0.9rem; + font-weight: 600; + transition: all 0.2s; +} + +.btn--primary { + background: var(--gradient-primary); + color: white; +} +.btn--primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(168, 85, 247, 0.4); } + +.btn--ghost { + background: transparent; + border: 1px solid var(--glass-border); + color: var(--text-secondary); +} +.btn--ghost:hover { background: rgba(255, 255, 255, 0.05); } + +/* ---- Toasts ---- */ +.toast-container { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + z-index: 300; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toast { + padding: 0.8rem 1.5rem; + border-radius: var(--radius-md); + background: var(--bg-card); + border: 1px solid var(--glass-border); + backdrop-filter: blur(20px); + color: var(--text-primary); + font-size: 0.9rem; + animation: toastIn 0.4s ease, toastOut 0.4s ease 2.6s forwards; + white-space: nowrap; +} + +@keyframes toastIn { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateY(-10px); } } + +/* ---- Empty State ---- */ +.empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 4rem 2rem; + color: var(--text-muted); +} + +.empty-state__emoji { font-size: 4rem; margin-bottom: 1rem; } +.empty-state__title { font-size: 1.3rem; color: var(--text-secondary); margin-bottom: 0.5rem; } + +/* ---- Responsive ---- */ +@media (max-width: 768px) { + .header { flex-wrap: wrap; gap: 0.8rem; padding: 1rem; } + .header__stats { order: 3; width: 100%; justify-content: center; } + .creatures-grid { padding: 1rem; gap: 1rem; grid-template-columns: 1fr; } + .creature-card__actions { flex-wrap: wrap; } +} diff --git a/k8s.yaml b/k8s.yaml new file mode 100644 index 0000000..ce6d230 --- /dev/null +++ b/k8s.yaml @@ -0,0 +1,224 @@ +--- +# Namespace +apiVersion: v1 +kind: Namespace +metadata: + name: tamagotchi +--- +# PostgreSQL PVC +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: tamagotchi +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi +--- +# PostgreSQL Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: tamagotchi + labels: + app: postgres + tier: database +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + tier: database + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: tamagotchi + - name: POSTGRES_USER + value: tamagotchi + - name: POSTGRES_PASSWORD + value: tamagotchi123 + volumeMounts: + - name: postgres-storage + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 300m + memory: 256Mi + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: postgres-pvc +--- +# PostgreSQL Service +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: tamagotchi +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 +--- +# Tamagotchi API Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tamagotchi-api + namespace: tamagotchi + labels: + app: tamagotchi-api + tier: backend +spec: + replicas: 2 + selector: + matchLabels: + app: tamagotchi-api + template: + metadata: + labels: + app: tamagotchi-api + tier: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + spec: + containers: + - name: api + image: tamagotchi-api:latest + imagePullPolicy: Never + ports: + - containerPort: 8080 + env: + - name: DB_HOST + value: postgres + - name: DB_PORT + value: "5432" + - name: DB_NAME + value: tamagotchi + - name: DB_USER + value: tamagotchi + - name: DB_PASSWORD + value: tamagotchi123 + - name: DECAY_INTERVAL_MS + value: "15000" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +# Tamagotchi API Service +apiVersion: v1 +kind: Service +metadata: + name: tamagotchi-api + namespace: tamagotchi + labels: + app: tamagotchi-api +spec: + selector: + app: tamagotchi-api + ports: + - port: 8080 + targetPort: 8080 +--- +# Tamagotchi Frontend Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tamagotchi-frontend + namespace: tamagotchi + labels: + app: tamagotchi-frontend + tier: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: tamagotchi-frontend + template: + metadata: + labels: + app: tamagotchi-frontend + tier: frontend + spec: + containers: + - name: frontend + image: tamagotchi-frontend:latest + imagePullPolicy: Never + ports: + - containerPort: 80 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi +--- +# Tamagotchi Frontend Service +apiVersion: v1 +kind: Service +metadata: + name: tamagotchi-frontend + namespace: tamagotchi +spec: + selector: + app: tamagotchi-frontend + ports: + - port: 80 + targetPort: 80 +--- +# Prometheus ServiceMonitor for Tamagotchi API +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: tamagotchi-api-monitor + namespace: tamagotchi + labels: + release: kube-prometheus +spec: + selector: + matchLabels: + app: tamagotchi-api + endpoints: + - port: "8080" + path: /metrics + interval: 15s + namespaceSelector: + matchNames: + - tamagotchi