From 424c1e6bbe6f805d8762cfefd60a2bdd1c9bad18 Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Sat, 3 Jan 2026 13:53:48 -0600 Subject: [PATCH] Add admin editor, PNG monster icons, and mobile tap fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add admin.html with monster/user/settings management UI - Add admin API endpoints with adminOnly middleware - Add game_settings table for configurable settings - Replace emoji monster icons with PNG images (50px map, 100px battle) - Add mapgameimgs/ directory with default fallback images - Fix mobile geocache tap by checking for markers before preventDefault - Increase geocache marker touch target to 64x64px 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 4 + admin.html | 1199 ++++++++++++++++++++++++++++++++++++ database.js | 201 +++++- index.html | 43 +- mapgameimgs/default100.png | Bin 0 -> 15403 bytes mapgameimgs/default50.png | Bin 0 -> 5808 bytes server.js | 191 ++++++ 7 files changed, 1599 insertions(+), 39 deletions(-) create mode 100644 admin.html create mode 100755 mapgameimgs/default100.png create mode 100755 mapgameimgs/default50.png diff --git a/Dockerfile b/Dockerfile index af553f8..5eeeabf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ RUN npm install COPY server.js ./ COPY database.js ./ COPY index.html ./ +COPY admin.html ./ COPY manifest.json ./ COPY service-worker.js ./ @@ -24,6 +25,9 @@ COPY .env* ./ # Copy PWA icons COPY icon-*.png ./ +# Copy monster images +COPY mapgameimgs ./mapgameimgs + # Copy .well-known directory for app verification COPY .well-known ./.well-known diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..b1ab10f --- /dev/null +++ b/admin.html @@ -0,0 +1,1199 @@ + + + + + + HikeMap Admin + + + + + + + + + + + + + + + + + + diff --git a/database.js b/database.js index 20f62b2..b59172b 100644 --- a/database.js +++ b/database.js @@ -124,12 +124,35 @@ class HikeMapDB { level_scale_hp INTEGER NOT NULL, level_scale_atk INTEGER NOT NULL, level_scale_def INTEGER NOT NULL, + min_level INTEGER DEFAULT 1, + max_level INTEGER DEFAULT 5, + spawn_weight INTEGER DEFAULT 100, dialogues TEXT NOT NULL, enabled BOOLEAN DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); + // Add columns if they don't exist (migration for existing databases) + try { + this.db.exec(`ALTER TABLE monster_types ADD COLUMN min_level INTEGER DEFAULT 1`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE monster_types ADD COLUMN max_level INTEGER DEFAULT 5`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`); + } catch (e) { /* Column already exists */ } + + // Game settings table - key/value store for game configuration + this.db.exec(` + CREATE TABLE IF NOT EXISTS game_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + // Create indexes for performance this.db.exec(` CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); @@ -526,21 +549,38 @@ class HikeMapDB { createMonsterType(monsterData) { const stmt = this.db.prepare(` - INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward, level_scale_hp, level_scale_atk, level_scale_def, dialogues, enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward, + level_scale_hp, level_scale_atk, level_scale_def, min_level, max_level, spawn_weight, dialogues, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); + // Support both camelCase (legacy) and snake_case (new admin UI) field names + const baseHp = monsterData.baseHp || monsterData.base_hp; + const baseAtk = monsterData.baseAtk || monsterData.base_atk; + const baseDef = monsterData.baseDef || monsterData.base_def; + const xpReward = monsterData.xpReward || monsterData.base_xp; + const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 }; + const minLevel = monsterData.minLevel || monsterData.min_level || 1; + const maxLevel = monsterData.maxLevel || monsterData.max_level || 5; + const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100; + const dialogues = typeof monsterData.dialogues === 'string' + ? monsterData.dialogues + : JSON.stringify(monsterData.dialogues); + return stmt.run( - monsterData.id, + monsterData.id || monsterData.key, monsterData.name, - monsterData.icon, - monsterData.baseHp, - monsterData.baseAtk, - monsterData.baseDef, - monsterData.xpReward, - monsterData.levelScale.hp, - monsterData.levelScale.atk, - monsterData.levelScale.def, - JSON.stringify(monsterData.dialogues), + monsterData.icon || '🟢', + baseHp, + baseAtk, + baseDef, + xpReward, + levelScale.hp, + levelScale.atk, + levelScale.def, + minLevel, + maxLevel, + spawnWeight, + dialogues, monsterData.enabled !== false ? 1 : 0 ); } @@ -550,20 +590,36 @@ class HikeMapDB { UPDATE monster_types SET name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?, xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?, - dialogues = ?, enabled = ? + min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ? WHERE id = ? `); + // Support both camelCase (legacy) and snake_case (new admin UI) field names + const baseHp = monsterData.baseHp || monsterData.base_hp; + const baseAtk = monsterData.baseAtk || monsterData.base_atk; + const baseDef = monsterData.baseDef || monsterData.base_def; + const xpReward = monsterData.xpReward || monsterData.base_xp; + const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 }; + const minLevel = monsterData.minLevel || monsterData.min_level || 1; + const maxLevel = monsterData.maxLevel || monsterData.max_level || 5; + const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100; + const dialogues = typeof monsterData.dialogues === 'string' + ? monsterData.dialogues + : JSON.stringify(monsterData.dialogues); + return stmt.run( monsterData.name, - monsterData.icon, - monsterData.baseHp, - monsterData.baseAtk, - monsterData.baseDef, - monsterData.xpReward, - monsterData.levelScale.hp, - monsterData.levelScale.atk, - monsterData.levelScale.def, - JSON.stringify(monsterData.dialogues), + monsterData.icon || '🟢', + baseHp, + baseAtk, + baseDef, + xpReward, + levelScale.hp, + levelScale.atk, + levelScale.def, + minLevel, + maxLevel, + spawnWeight, + dialogues, monsterData.enabled !== false ? 1 : 0, id ); @@ -631,6 +687,107 @@ class HikeMapDB { console.log('Seeded default monster: Moop'); } + // Admin: Get all users with their RPG stats + getAllUsers() { + const stmt = this.db.prepare(` + SELECT u.id, u.username, u.email, u.created_at, u.total_points, u.finds_count, + u.avatar_icon, u.avatar_color, u.is_admin, + r.character_name, r.race, r.class, r.level, r.xp, r.hp, r.max_hp, + r.mp, r.max_mp, r.atk, r.def, r.unlocked_skills + FROM users u + LEFT JOIN rpg_stats r ON u.id = r.user_id + ORDER BY u.created_at DESC + `); + return stmt.all(); + } + + // Admin: Update user RPG stats + updateUserRpgStats(userId, stats) { + const stmt = this.db.prepare(` + UPDATE rpg_stats SET + level = ?, xp = ?, hp = ?, max_hp = ?, mp = ?, max_mp = ?, + atk = ?, def = ?, unlocked_skills = ?, updated_at = datetime('now') + WHERE user_id = ? + `); + const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : '["basic_attack"]'; + return stmt.run( + stats.level || 1, + stats.xp || 0, + stats.hp || 100, + stats.maxHp || 100, + stats.mp || 50, + stats.maxMp || 50, + stats.atk || 12, + stats.def || 8, + unlockedSkillsJson, + userId + ); + } + + // Admin: Reset user RPG progress + resetUserProgress(userId) { + const stmt = this.db.prepare(` + UPDATE rpg_stats SET + level = 1, xp = 0, hp = 100, max_hp = 100, mp = 50, max_mp = 50, + atk = 12, def = 8, unlocked_skills = '["basic_attack"]', + updated_at = datetime('now') + WHERE user_id = ? + `); + const result = stmt.run(userId); + + // Also clear their monster entourage + this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`).run(userId); + + return result; + } + + // Game settings methods + getSetting(key) { + const stmt = this.db.prepare(`SELECT value FROM game_settings WHERE key = ?`); + const row = stmt.get(key); + return row ? row.value : null; + } + + setSetting(key, value) { + const stmt = this.db.prepare(` + INSERT INTO game_settings (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now') + `); + return stmt.run(key, value); + } + + getAllSettings() { + const stmt = this.db.prepare(`SELECT key, value FROM game_settings`); + const rows = stmt.all(); + const settings = {}; + rows.forEach(row => { + // Try to parse JSON values + try { + settings[row.key] = JSON.parse(row.value); + } catch { + settings[row.key] = row.value; + } + }); + return settings; + } + + seedDefaultSettings() { + const defaults = { + monsterSpawnInterval: 30000, + maxMonstersPerPlayer: 10, + xpMultiplier: 1.0, + combatEnabled: true + }; + + for (const [key, value] of Object.entries(defaults)) { + const existing = this.getSetting(key); + if (existing === null) { + this.setSetting(key, JSON.stringify(value)); + } + } + } + close() { if (this.db) { this.db.close(); diff --git a/index.html b/index.html index dd24c94..5fbd06e 100644 --- a/index.html +++ b/index.html @@ -447,28 +447,26 @@ } /* Geocache styles */ .geocache-marker { - background: transparent; - border: none; - font-size: 20px; cursor: pointer; - /* Larger tap target for mobile */ display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: 64px; + height: 64px; + /* DEBUG: visible halo showing tap zone - remove when confirmed working */ + background: rgba(255, 167, 38, 0.3); + border: 2px dashed rgba(255, 167, 38, 0.7); + border-radius: 50%; } .geocache-marker:hover { transform: scale(1.2); } .geocache-marker.in-range { - /* Removed pulse animation - was causing disappearing */ box-shadow: 0 0 20px rgba(255, 167, 38, 0.8); - border-radius: 50%; } - /* Increase tap area for geocache icon */ .geocache-marker i { - padding: 10px; + font-size: 36px; + pointer-events: none; /* Parent handles all touches */ } .geocache-dialog { position: fixed !important; @@ -1915,7 +1913,9 @@ background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%); } .monster-icon { - font-size: 44px; + width: 50px; + height: 50px; + object-fit: contain; filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6)); animation: monster-bob 2s ease-in-out infinite; } @@ -2261,7 +2261,9 @@ margin-bottom: 6px; } .monster-entry-icon { - font-size: 24px; + width: 32px; + height: 32px; + object-fit: contain; margin-right: 8px; } .monster-entry-name { @@ -4591,9 +4593,9 @@ const marker = L.marker([geocache.lat, geocache.lng], { icon: L.divIcon({ className: 'geocache-marker', - html: ``, - iconSize: [48, 48], - iconAnchor: [24, 40] + html: ``, + iconSize: [64, 64], + iconAnchor: [32, 32] // Centered for intuitive mobile tapping }), zIndexOffset: 1000 // Ensure geocaches appear above GPS markers }); @@ -6996,6 +6998,11 @@ // Fix for Chrome and PWA - use native addEventListener with passive: false mapContainer.addEventListener('touchstart', function(e) { if (navMode && e.touches.length === 1) { + // Check if touch target is a geocache marker - let those through to Leaflet + if (e.target.closest('.geocache-marker')) { + return; // Let Leaflet handle geocache taps + } + // ALWAYS prevent default in navMode to stop Leaflet from synthesizing dblclick // This fixes the 50/50 bug where both touchend and dblclick handlers race e.preventDefault(); @@ -9639,7 +9646,8 @@ const iconHtml = `
-
${monsterType.icon}
+ ${monsterType.name}
`; @@ -9857,7 +9865,8 @@ entry.innerHTML = `
${index === combatState.selectedTargetIndex ? 'â–¶' : ''} - ${monster.data.icon} + ${monster.data.name} ${monster.data.name} Lv.${monster.level}
diff --git a/mapgameimgs/default100.png b/mapgameimgs/default100.png new file mode 100755 index 0000000000000000000000000000000000000000..d08e3849dce144b4bf724f6a5dafebfe26052020 GIT binary patch literal 15403 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 { // Serve .well-known directory for app verification app.use('/.well-known', express.static(path.join(__dirname, '.well-known'))); +// Serve monster images +app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs'))); + // Serve other static files app.use(express.static(path.join(__dirname))); @@ -382,6 +385,17 @@ function optionalAuth(req, res, next) { next(); } +// Admin-only middleware - requires valid auth AND admin status +function adminOnly(req, res, next) { + authenticateToken(req, res, () => { + const user = db.getUserById(req.user.userId); + if (!user || !user.is_admin) { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); + }); +} + // ============================================ // Authentication Endpoints // ============================================ @@ -906,6 +920,180 @@ app.delete('/api/user/monsters/:monsterId', authenticateToken, (req, res) => { } }); +// ============================================ +// Admin Endpoints +// ============================================ + +// Serve admin page +app.get('/admin', (req, res) => { + res.sendFile(path.join(__dirname, 'admin.html')); +}); + +// Get all monster types (admin - includes disabled) +app.get('/api/admin/monster-types', adminOnly, (req, res) => { + try { + const types = db.getAllMonsterTypes(false); // Include disabled + // Return with snake_case for frontend compatibility + const formatted = types.map(t => ({ + id: t.id, + key: t.id, // Use id as key for compatibility + name: t.name, + icon: t.icon, + min_level: t.min_level || 1, + max_level: t.max_level || 5, + base_hp: t.base_hp, + base_atk: t.base_atk, + base_def: t.base_def, + base_xp: t.xp_reward, + spawn_weight: t.spawn_weight || 100, + dialogues: t.dialogues, + enabled: !!t.enabled, + created_at: t.created_at + })); + res.json({ monsterTypes: formatted }); + } catch (err) { + console.error('Admin get monster types error:', err); + res.status(500).json({ error: 'Failed to get monster types' }); + } +}); + +// Create monster type +app.post('/api/admin/monster-types', adminOnly, (req, res) => { + try { + const data = req.body; + // Accept either 'id' or 'key' as the monster identifier + const monsterId = data.id || data.key; + if (!monsterId || !data.name) { + return res.status(400).json({ error: 'Missing required fields (key and name)' }); + } + // Ensure id is set for the database function + data.id = monsterId; + db.createMonsterType(data); + res.json({ success: true }); + } catch (err) { + console.error('Admin create monster type error:', err); + res.status(500).json({ error: 'Failed to create monster type' }); + } +}); + +// Update monster type +app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => { + try { + const data = req.body; + db.updateMonsterType(req.params.id, data); + res.json({ success: true }); + } catch (err) { + console.error('Admin update monster type error:', err); + res.status(500).json({ error: 'Failed to update monster type' }); + } +}); + +// Delete monster type +app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => { + try { + db.deleteMonsterType(req.params.id); + res.json({ success: true }); + } catch (err) { + console.error('Admin delete monster type error:', err); + res.status(500).json({ error: 'Failed to delete monster type' }); + } +}); + +// Get all users +app.get('/api/admin/users', adminOnly, (req, res) => { + try { + const users = db.getAllUsers(); + // Return flat structure with snake_case for frontend compatibility + const formatted = users.map(u => ({ + id: u.id, + username: u.username, + email: u.email, + created_at: u.created_at, + total_points: u.total_points, + finds_count: u.finds_count, + avatar_icon: u.avatar_icon, + avatar_color: u.avatar_color, + is_admin: !!u.is_admin, + character_name: u.character_name, + race: u.race, + class: u.class, + level: u.level || 1, + xp: u.xp || 0, + hp: u.hp || 0, + max_hp: u.max_hp || 0, + mp: u.mp || 0, + max_mp: u.max_mp || 0, + atk: u.atk || 0, + def: u.def || 0, + unlocked_skills: u.unlocked_skills + })); + res.json({ users: formatted }); + } catch (err) { + console.error('Admin get users error:', err); + res.status(500).json({ error: 'Failed to get users' }); + } +}); + +// Update user RPG stats +app.put('/api/admin/users/:id', adminOnly, (req, res) => { + try { + const stats = req.body; + db.updateUserRpgStats(req.params.id, stats); + res.json({ success: true }); + } catch (err) { + console.error('Admin update user error:', err); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +// Toggle admin status +app.put('/api/admin/users/:id/admin', adminOnly, (req, res) => { + try { + const { isAdmin } = req.body; + db.setUserAdmin(req.params.id, isAdmin); + res.json({ success: true }); + } catch (err) { + console.error('Admin toggle admin error:', err); + res.status(500).json({ error: 'Failed to toggle admin status' }); + } +}); + +// Reset user progress +app.delete('/api/admin/users/:id/reset', adminOnly, (req, res) => { + try { + db.resetUserProgress(req.params.id); + res.json({ success: true }); + } catch (err) { + console.error('Admin reset user error:', err); + res.status(500).json({ error: 'Failed to reset user progress' }); + } +}); + +// Get game settings +app.get('/api/admin/settings', adminOnly, (req, res) => { + try { + const settings = db.getAllSettings(); + res.json(settings); + } catch (err) { + console.error('Admin get settings error:', err); + res.status(500).json({ error: 'Failed to get settings' }); + } +}); + +// Update game settings +app.put('/api/admin/settings', adminOnly, (req, res) => { + try { + const settings = req.body; + for (const [key, value] of Object.entries(settings)) { + db.setSetting(key, JSON.stringify(value)); + } + res.json({ success: true }); + } catch (err) { + console.error('Admin update settings error:', err); + res.status(500).json({ error: 'Failed to update settings' }); + } +}); + // Function to send push notification to all subscribers async function sendPushNotification(title, body, data = {}) { const notification = { @@ -1163,6 +1351,9 @@ server.listen(PORT, async () => { // Seed default monsters if they don't exist db.seedDefaultMonsters(); + // Seed default game settings if they don't exist + db.seedDefaultSettings(); + // Clean expired tokens periodically setInterval(() => { try {