Browse Source

Add admin editor, PNG monster icons, and mobile tap fixes

- 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 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
424c1e6bbe
  1. 4
      Dockerfile
  2. 1199
      admin.html
  3. 201
      database.js
  4. 43
      index.html
  5. BIN
      mapgameimgs/default100.png
  6. BIN
      mapgameimgs/default50.png
  7. 191
      server.js

4
Dockerfile

@ -15,6 +15,7 @@ RUN npm install
COPY server.js ./ COPY server.js ./
COPY database.js ./ COPY database.js ./
COPY index.html ./ COPY index.html ./
COPY admin.html ./
COPY manifest.json ./ COPY manifest.json ./
COPY service-worker.js ./ COPY service-worker.js ./
@ -24,6 +25,9 @@ COPY .env* ./
# Copy PWA icons # Copy PWA icons
COPY icon-*.png ./ COPY icon-*.png ./
# Copy monster images
COPY mapgameimgs ./mapgameimgs
# Copy .well-known directory for app verification # Copy .well-known directory for app verification
COPY .well-known ./.well-known COPY .well-known ./.well-known

1199
admin.html
File diff suppressed because it is too large
View File

201
database.js

@ -124,12 +124,35 @@ class HikeMapDB {
level_scale_hp INTEGER NOT NULL, level_scale_hp INTEGER NOT NULL,
level_scale_atk INTEGER NOT NULL, level_scale_atk INTEGER NOT NULL,
level_scale_def 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, dialogues TEXT NOT NULL,
enabled BOOLEAN DEFAULT 1, enabled BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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 // Create indexes for performance
this.db.exec(` this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id);
@ -526,21 +549,38 @@ class HikeMapDB {
createMonsterType(monsterData) { createMonsterType(monsterData) {
const stmt = this.db.prepare(` 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( return stmt.run(
monsterData.id,
monsterData.id || monsterData.key,
monsterData.name, 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 monsterData.enabled !== false ? 1 : 0
); );
} }
@ -550,20 +590,36 @@ class HikeMapDB {
UPDATE monster_types SET UPDATE monster_types SET
name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?, name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?,
xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?, xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?,
dialogues = ?, enabled = ?
min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ?
WHERE id = ? 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( return stmt.run(
monsterData.name, 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, monsterData.enabled !== false ? 1 : 0,
id id
); );
@ -631,6 +687,107 @@ class HikeMapDB {
console.log('Seeded default monster: Moop'); 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() { close() {
if (this.db) { if (this.db) {
this.db.close(); this.db.close();

43
index.html

@ -447,28 +447,26 @@
} }
/* Geocache styles */ /* Geocache styles */
.geocache-marker { .geocache-marker {
background: transparent;
border: none;
font-size: 20px;
cursor: pointer; cursor: pointer;
/* Larger tap target for mobile */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .geocache-marker:hover {
transform: scale(1.2); transform: scale(1.2);
} }
.geocache-marker.in-range { .geocache-marker.in-range {
/* Removed pulse animation - was causing disappearing */
box-shadow: 0 0 20px rgba(255, 167, 38, 0.8); box-shadow: 0 0 20px rgba(255, 167, 38, 0.8);
border-radius: 50%;
} }
/* Increase tap area for geocache icon */
.geocache-marker i { .geocache-marker i {
padding: 10px;
font-size: 36px;
pointer-events: none; /* Parent handles all touches */
} }
.geocache-dialog { .geocache-dialog {
position: fixed !important; position: fixed !important;
@ -1915,7 +1913,9 @@
background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%); background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%);
} }
.monster-icon { .monster-icon {
font-size: 44px;
width: 50px;
height: 50px;
object-fit: contain;
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6)); filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6));
animation: monster-bob 2s ease-in-out infinite; animation: monster-bob 2s ease-in-out infinite;
} }
@ -2261,7 +2261,9 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.monster-entry-icon { .monster-entry-icon {
font-size: 24px;
width: 32px;
height: 32px;
object-fit: contain;
margin-right: 8px; margin-right: 8px;
} }
.monster-entry-name { .monster-entry-name {
@ -4591,9 +4593,9 @@
const marker = L.marker([geocache.lat, geocache.lng], { const marker = L.marker([geocache.lat, geocache.lng], {
icon: L.divIcon({ icon: L.divIcon({
className: 'geocache-marker', className: 'geocache-marker',
html: `<i class="mdi ${iconClass}" style="font-size: 32px; color: ${color}; opacity: ${opacity};"></i>`,
iconSize: [48, 48],
iconAnchor: [24, 40]
html: `<i class="mdi ${iconClass}" style="color: ${color}; opacity: ${opacity};"></i>`,
iconSize: [64, 64],
iconAnchor: [32, 32] // Centered for intuitive mobile tapping
}), }),
zIndexOffset: 1000 // Ensure geocaches appear above GPS markers zIndexOffset: 1000 // Ensure geocaches appear above GPS markers
}); });
@ -6996,6 +6998,11 @@
// Fix for Chrome and PWA - use native addEventListener with passive: false // Fix for Chrome and PWA - use native addEventListener with passive: false
mapContainer.addEventListener('touchstart', function(e) { mapContainer.addEventListener('touchstart', function(e) {
if (navMode && e.touches.length === 1) { 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 // ALWAYS prevent default in navMode to stop Leaflet from synthesizing dblclick
// This fixes the 50/50 bug where both touchend and dblclick handlers race // This fixes the 50/50 bug where both touchend and dblclick handlers race
e.preventDefault(); e.preventDefault();
@ -9639,7 +9646,8 @@
const iconHtml = ` const iconHtml = `
<div class="monster-marker" data-monster-id="${monster.id}"> <div class="monster-marker" data-monster-id="${monster.id}">
<div class="monster-icon">${monsterType.icon}</div>
<img class="monster-icon" src="/mapgameimgs/${monster.type}50.png"
onerror="this.src='/mapgameimgs/default50.png'" alt="${monsterType.name}">
<div class="monster-dialogue-bubble" style="display: none;"></div> <div class="monster-dialogue-bubble" style="display: none;"></div>
</div> </div>
`; `;
@ -9857,7 +9865,8 @@
entry.innerHTML = ` entry.innerHTML = `
<div class="monster-entry-header"> <div class="monster-entry-header">
${index === combatState.selectedTargetIndex ? '<span class="target-arrow"></span>' : ''} ${index === combatState.selectedTargetIndex ? '<span class="target-arrow"></span>' : ''}
<span class="monster-entry-icon">${monster.data.icon}</span>
<img class="monster-entry-icon" src="/mapgameimgs/${monster.type}100.png"
onerror="this.src='/mapgameimgs/default100.png'" alt="${monster.data.name}">
<span class="monster-entry-name">${monster.data.name} Lv.${monster.level}</span> <span class="monster-entry-name">${monster.data.name} Lv.${monster.level}</span>
</div> </div>
<div class="monster-entry-hp"> <div class="monster-entry-hp">

BIN
mapgameimgs/default100.png

After

Width: 102  |  Height: 100  |  Size: 15 KiB

BIN
mapgameimgs/default50.png

After

Width: 50  |  Height: 50  |  Size: 5.7 KiB

191
server.js

@ -71,6 +71,9 @@ app.get('/default.kml', async (req, res) => {
// Serve .well-known directory for app verification // Serve .well-known directory for app verification
app.use('/.well-known', express.static(path.join(__dirname, '.well-known'))); 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 // Serve other static files
app.use(express.static(path.join(__dirname))); app.use(express.static(path.join(__dirname)));
@ -382,6 +385,17 @@ function optionalAuth(req, res, next) {
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 // 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 // Function to send push notification to all subscribers
async function sendPushNotification(title, body, data = {}) { async function sendPushNotification(title, body, data = {}) {
const notification = { const notification = {
@ -1163,6 +1351,9 @@ server.listen(PORT, async () => {
// Seed default monsters if they don't exist // Seed default monsters if they don't exist
db.seedDefaultMonsters(); db.seedDefaultMonsters();
// Seed default game settings if they don't exist
db.seedDefaultSettings();
// Clean expired tokens periodically // Clean expired tokens periodically
setInterval(() => { setInterval(() => {
try { try {

Loading…
Cancel
Save