UI overhaul

This commit is contained in:
Anuj K 2025-09-03 21:55:40 +05:30
parent ca5a55a612
commit e0563ba5e4
9 changed files with 317 additions and 679 deletions

View file

@ -1,7 +1,4 @@
// backend/retroClient.js
'use strict'; 'use strict';
const { const {
buildAuthorization, buildAuthorization,
getUserProfile, getUserProfile,
@ -9,35 +6,27 @@ const {
getGame, getGame,
getGameInfoAndUserProgress getGameInfoAndUserProgress
} = require('@retroachievements/api'); } = require('@retroachievements/api');
class RetroClient { class RetroClient {
constructor() { constructor() {
const RA_USER = process.env.RA_USER; const RA_USER = process.env.RA_USER;
const RA_KEY = process.env.RA_KEY; const RA_KEY = process.env.RA_KEY;
if (!RA_USER || !RA_KEY) { if (!RA_USER || !RA_KEY) {
throw new Error('RetroAchievements username and API key must be set in .env file'); throw new Error('RetroAchievements username and API key must be set in .env file');
} }
this.authorization = buildAuthorization({ this.authorization = buildAuthorization({
username: RA_USER, username: RA_USER,
webApiKey: RA_KEY webApiKey: RA_KEY
}); });
console.log('🎮 RetroAchievements API client initialized'); console.log('RetroAchievements API client initialized');
} }
// Helper method to build correct image URLs // Helper method to build correct image URLs
buildImageUrl(imagePath, baseUrl) { buildImageUrl(imagePath, baseUrl) {
if (!imagePath) return null; if (!imagePath) return null;
const toStr = (v) => (typeof v === 'string' ? v : String(v)); const toStr = (v) => (typeof v === 'string' ? v : String(v));
// If full URL provided, return as-is // If full URL provided, return as-is
if (/^https?:\/\//i.test(imagePath)) { if (/^https?:\/\//i.test(imagePath)) {
return imagePath; return imagePath;
} }
const pathStr = toStr(imagePath); const pathStr = toStr(imagePath);
// Handle full server-relative paths that start with / // Handle full server-relative paths that start with /
if (pathStr.startsWith('/')) { if (pathStr.startsWith('/')) {
@ -48,84 +37,74 @@ class RetroClient {
} }
return `https://media.retroachievements.org${pathStr}`; return `https://media.retroachievements.org${pathStr}`;
} }
// Normalize base URL (strip trailing slashes) // Normalize base URL (strip trailing slashes)
const normalizedBase = toStr(baseUrl || '').replace(/\/+$/, ''); const normalizedBase = toStr(baseUrl || '').replace(/\/+$/, '');
// Special handling for badges - always ensure .png extension // Special handling for badges - always ensure .png extension
const isBadgeContext = /\/Badge(\/|$)/i.test(normalizedBase); const isBadgeContext = /\/Badge(\/|$)/i.test(normalizedBase);
if (isBadgeContext) { if (isBadgeContext) {
const cleanImagePath = pathStr.replace(/\.(png|jpe?g|gif|webp)$/i, ''); const cleanImagePath = pathStr.replace(/\.(png|jpe?g|gif|webp)$/i, '');
return `${normalizedBase}/${cleanImagePath}.png`; return `${normalizedBase}/${cleanImagePath}.png`;
} }
// For other images (UserPic, Images), build normally // For other images (UserPic, Images), build normally
return `${normalizedBase}/${pathStr}`; return `${normalizedBase}/${pathStr}`;
} }
async getUserProfile(username) { async getUserProfile(username) {
try { try {
console.log(`📊 Fetching profile for user: ${username}`); console.log(`Fetching profile for user: ${username}`);
const profile = await getUserProfile(this.authorization, { const profile = await getUserProfile(this.authorization, {
username username
}); });
return profile; return profile;
} catch (error) { } catch (error) {
console.error(`Error fetching user profile for ${username}:`, error.message); console.error(`Error fetching user profile for ${username}:`, error.message);
throw new Error(`Failed to fetch user profile: ${error.message}`); throw new Error(`Failed to fetch user profile: ${error.message}`);
} }
} }
async getUserRecentAchievements(username, count = 10) { async getUserRecentAchievements(username, count = 10) {
try { try {
console.log(`🏆 Fetching recent achievements for user: ${username}`); console.log(`Fetching recent achievements for user: ${username}`);
const achievements = await getUserRecentAchievements(this.authorization, { const achievements = await getUserRecentAchievements(this.authorization, {
username, username,
count count
}); });
return achievements; return achievements;
} catch (error) { } catch (error) {
console.error(`Error fetching recent achievements for ${username}:`, error.message); console.error(`Error fetching recent achievements for ${username}:`, error.message);
throw new Error(`Failed to fetch recent achievements: ${error.message}`); throw new Error(`Failed to fetch recent achievements: ${error.message}`);
} }
} }
async getGameInfo(gameId) { async getGameInfo(gameId) {
try { try {
console.log(`🎮 Fetching game info for ID: ${gameId}`); console.log(`Fetching game info for ID: ${gameId}`);
const game = await getGame(this.authorization, { const game = await getGame(this.authorization, {
gameId gameId
}); });
return game; return game;
} catch (error) { } catch (error) {
console.error(`Error fetching game info for ID ${gameId}:`, error.message); console.error(`Error fetching game info for ID ${gameId}:`, error.message);
throw new Error(`Failed to fetch game info: ${error.message}`); throw new Error(`Failed to fetch game info: ${error.message}`);
} }
} }
async getUserData(username) { async getUserData(username) {
try { try {
const profile = await this.getUserProfile(username); const profile = await this.getUserProfile(username);
let lastGame = null; let lastGame = null;
let lastAchievement = null; let lastAchievement = null;
if (profile.lastGameId) { if (profile.lastGameId) {
try { try {
lastGame = await this.getGameInfo(profile.lastGameId); lastGame = await this.getGameInfo(profile.lastGameId);
} catch (gameError) { } catch (gameError) {
console.warn(`⚠️ Could not fetch last game info: ${gameError.message}`); console.warn(`Could not fetch last game info: ${gameError.message}`);
} }
} }
try { try {
const recentAchievements = await this.getUserRecentAchievements(username, 1); const recentAchievements = await this.getUserRecentAchievements(username, 1);
if (recentAchievements && recentAchievements.length > 0) { if (recentAchievements && recentAchievements.length > 0) {
lastAchievement = recentAchievements[0]; lastAchievement = recentAchievements[0];
} }
} catch (achievementError) { } catch (achievementError) {
console.warn(`⚠️ Could not fetch recent achievements: ${achievementError.message}`); console.warn(`Could not fetch recent achievements: ${achievementError.message}`);
} }
return { return {
user: { user: {
username: profile.user, username: profile.user,
@ -140,65 +119,60 @@ class RetroClient {
memberSince: profile.memberSince || null, memberSince: profile.memberSince || null,
rank: profile.rank || null rank: profile.rank || null
}, },
lastGame: lastGame ? lastGame: lastGame ? {
{ id: lastGame.gameId,
id: lastGame.gameId, title: lastGame.title,
title: lastGame.title, icon: this.buildImageUrl(
icon: this.buildImageUrl( lastGame.gameIcon,
lastGame.gameIcon, 'https://media.retroachievements.org/Images'
'https://media.retroachievements.org/Images' ),
), consoleName: lastGame.consoleName
consoleName: lastGame.consoleName } :
} :
null, null,
lastAchievement: lastAchievement ? lastAchievement: lastAchievement ? {
{ id: lastAchievement.achievementId,
id: lastAchievement.achievementId, title: lastAchievement.title,
title: lastAchievement.title, description: lastAchievement.description,
description: lastAchievement.description, points: lastAchievement.points,
points: lastAchievement.points, trueRatio: lastAchievement.trueRatio,
trueRatio: lastAchievement.trueRatio, gameTitle: lastAchievement.gameTitle,
gameTitle: lastAchievement.gameTitle, gameIcon: this.buildImageUrl(
gameIcon: this.buildImageUrl( lastAchievement.gameIcon,
lastAchievement.gameIcon, 'https://media.retroachievements.org/Images'
'https://media.retroachievements.org/Images' ),
), badgeName: this.buildImageUrl(
badgeName: this.buildImageUrl( lastAchievement.badgeName,
lastAchievement.badgeName, 'https://media.retroachievements.org/Badge'
'https://media.retroachievements.org/Badge' ),
), dateEarned: lastAchievement.dateEarned
dateEarned: lastAchievement.dateEarned } :
} :
null null
}; };
} catch (error) { } catch (error) {
console.error(`Error in getUserData for ${username}:`, error.message); console.error(`Error in getUserData for ${username}:`, error.message);
throw error; throw error;
} }
} }
async getOverlayData(username, count = 5) { async getOverlayData(username, count = 5) {
try { try {
const recentAchievements = await this.getUserRecentAchievements(username, count); const recentAchievements = await this.getUserRecentAchievements(username, count);
let gameProgress = null; let gameProgress = null;
let gameId = null; let gameId = null;
// First, try to get gameId from the most recent achievement. This is the most "live" data. // First, try to get gameId from the most recent achievement. This is the most "live" data.
if (recentAchievements && recentAchievements.length > 0) { if (recentAchievements && recentAchievements.length > 0) {
gameId = recentAchievements[0].gameId; gameId = recentAchievements[0].gameId;
} else { } else {
// If no recent achievements, fall back to the user's profile to find their last played game. // If no recent achievements, fall back to the user's profile to find their last played game.
console.log(`🤔 No recent achievements for ${username}. Checking profile for last game.`); console.log(`No recent achievements for ${username}. Checking profile for last game.`);
try { try {
const profile = await this.getUserProfile(username); const profile = await this.getUserProfile(username);
if (profile.lastGameId) { if (profile.lastGameId) {
gameId = profile.lastGameId; gameId = profile.lastGameId;
} }
} catch (profileError) { } catch (profileError) {
console.warn(`⚠️ Could not fetch profile to find last game: ${profileError.message}`); console.warn(`Could not fetch profile to find last game: ${profileError.message}`);
} }
} }
// If we found a gameId from either method, fetch its progress. // If we found a gameId from either method, fetch its progress.
if (gameId) { if (gameId) {
try { try {
@ -206,7 +180,6 @@ class RetroClient {
username, username,
gameId gameId
}); });
gameProgress = { gameProgress = {
title: progressData.title, title: progressData.title,
icon: this.buildImageUrl(progressData.imageIcon, 'https://media.retroachievements.org/Images'), icon: this.buildImageUrl(progressData.imageIcon, 'https://media.retroachievements.org/Images'),
@ -216,10 +189,9 @@ class RetroClient {
completion: progressData.userCompletion, completion: progressData.userCompletion,
}; };
} catch (progressError) { } catch (progressError) {
console.warn(`⚠️ Could not fetch game progress for gameId ${gameId}: ${progressError.message}`); console.warn(`Could not fetch game progress for gameId ${gameId}: ${progressError.message}`);
} }
} }
const formattedAchievements = recentAchievements.map((achievement) => { const formattedAchievements = recentAchievements.map((achievement) => {
return { return {
id: achievement.achievementId, id: achievement.achievementId,
@ -239,7 +211,6 @@ class RetroClient {
dateEarned: achievement.dateEarned dateEarned: achievement.dateEarned
}; };
}); });
return { return {
username, username,
gameProgress, gameProgress,
@ -247,10 +218,9 @@ class RetroClient {
lastUpdated: new Date().toISOString() lastUpdated: new Date().toISOString()
}; };
} catch (error) { } catch (error) {
console.error(`Error in getOverlayData for ${username}:`, error.message); console.error(`Error in getOverlayData for ${username}:`, error.message);
throw error; throw error;
} }
} }
} }
module.exports = new RetroClient();
module.exports = new RetroClient();

View file

@ -1,19 +1,16 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const retroClient = require('../retroClient'); const retroClient = require('../retroClient');
// GET /api/userinfo?username=xxx // GET /api/userinfo?username=xxx
router.get('/userinfo', async (req, res) => { router.get('/userinfo', async (req, res) => {
try { try {
const { username } = req.query; const { username } = req.query;
if (!username) { if (!username) {
return res.status(400).json({ return res.status(400).json({
error: 'Username is required', error: 'Username is required',
message: 'Please provide a username parameter' message: 'Please provide a username parameter'
}); });
} }
// Validate username (basic sanitization) // Validate username (basic sanitization)
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return res.status(400).json({ return res.status(400).json({
@ -21,18 +18,13 @@ router.get('/userinfo', async (req, res) => {
message: 'Username can only contain letters, numbers, underscores, and hyphens' message: 'Username can only contain letters, numbers, underscores, and hyphens'
}); });
} }
console.log(`API request for user: ${username}`);
console.log(`📡 API request for user: ${username}`);
// Fetch real user data from RetroAchievements // Fetch real user data from RetroAchievements
const userData = await retroClient.getUserData(username); const userData = await retroClient.getUserData(username);
console.log(`Successfully fetched data for user: ${username}`);
console.log(`✅ Successfully fetched data for user: ${username}`);
res.json(userData); res.json(userData);
} catch (error) { } catch (error) {
console.error('❌ Error in /api/userinfo:', error); console.error('Error in /api/userinfo:', error);
// Handle specific API errors // Handle specific API errors
if (error.message.includes('User not found') || error.message.includes('Invalid user')) { if (error.message.includes('User not found') || error.message.includes('Invalid user')) {
return res.status(404).json({ return res.status(404).json({
@ -40,7 +32,6 @@ router.get('/userinfo', async (req, res) => {
message: `RetroAchievements user '${req.query.username}' not found` message: `RetroAchievements user '${req.query.username}' not found`
}); });
} }
// Handle rate limiting // Handle rate limiting
if (error.message.includes('rate limit') || error.message.includes('too many requests')) { if (error.message.includes('rate limit') || error.message.includes('too many requests')) {
return res.status(429).json({ return res.status(429).json({
@ -48,20 +39,17 @@ router.get('/userinfo', async (req, res) => {
message: 'Too many API requests. Please wait a moment and try again.' message: 'Too many API requests. Please wait a moment and try again.'
}); });
} }
res.status(500).json({ res.status(500).json({
error: 'Failed to fetch user information', error: 'Failed to fetch user information',
message: 'An error occurred while fetching data from RetroAchievements' message: 'An error occurred while fetching data from RetroAchievements'
}); });
} }
}); });
// GET /api/achievements/:username - For WebSocket data // GET /api/achievements/:username - For WebSocket data
router.get('/achievements/:username', async (req, res) => { router.get('/achievements/:username', async (req, res) => {
try { try {
const { username } = req.params; const { username } = req.params;
const { count = 5 } = req.query; const { count = 5 } = req.query;
// Validate username // Validate username
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return res.status(400).json({ return res.status(400).json({
@ -69,21 +57,15 @@ router.get('/achievements/:username', async (req, res) => {
message: 'Username can only contain letters, numbers, underscores, and hyphens' message: 'Username can only contain letters, numbers, underscores, and hyphens'
}); });
} }
// Validate count parameter // Validate count parameter
const achievementCount = Math.min(Math.max(parseInt(count) || 5, 1), 25); // Between 1 and 25 const achievementCount = Math.min(Math.max(parseInt(count) || 5, 1), 25); // Between 1 and 25
console.log(`API request for ${username}'s recent ${achievementCount} achievements`);
console.log(`🏆 API request for ${username}'s recent ${achievementCount} achievements`);
// Fetch real achievement data from RetroAchievements // Fetch real achievement data from RetroAchievements
const overlayData = await retroClient.getOverlayData(username, achievementCount); const overlayData = await retroClient.getOverlayData(username, achievementCount);
console.log(`Successfully fetched ${overlayData.recentAchievements.length} achievements for user: ${username}`);
console.log(`✅ Successfully fetched ${overlayData.recentAchievements.length} achievements for user: ${username}`);
res.json(overlayData); res.json(overlayData);
} catch (error) { } catch (error) {
console.error('❌ Error in /api/achievements:', error); console.error('Error in /api/achievements:', error);
// Handle specific API errors // Handle specific API errors
if (error.message.includes('User not found') || error.message.includes('Invalid user')) { if (error.message.includes('User not found') || error.message.includes('Invalid user')) {
return res.status(404).json({ return res.status(404).json({
@ -91,14 +73,12 @@ router.get('/achievements/:username', async (req, res) => {
message: `RetroAchievements user '${req.params.username}' not found` message: `RetroAchievements user '${req.params.username}' not found`
}); });
} }
res.status(500).json({ res.status(500).json({
error: 'Failed to fetch achievements', error: 'Failed to fetch achievements',
message: 'An error occurred while fetching achievements from RetroAchievements' message: 'An error occurred while fetching achievements from RetroAchievements'
}); });
} }
}); });
// GET /api/health - Enhanced health check that tests RA API connection // GET /api/health - Enhanced health check that tests RA API connection
router.get('/health', async (req, res) => { router.get('/health', async (req, res) => {
try { try {
@ -111,12 +91,11 @@ router.get('/health', async (req, res) => {
}); });
} catch (error) { } catch (error) {
res.status(503).json({ res.status(503).json({
status: 'ERROR', status: 'ERROR',
message: 'Service unavailable', message: 'Service unavailable',
retroAchievements: 'Connection failed', retroAchievements: 'Connection failed',
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
} }
}); });
module.exports = router;
module.exports = router;

View file

@ -1,78 +1,60 @@
// backend/server.js
// Load environment variables from .env file // Load environment variables from .env file
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const WebSocket = require('ws'); const WebSocket = require('ws');
const path = require('path'); const path = require('path');
// Create Express app and HTTP server // Create Express app and HTTP server
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// Middleware to parse JSON requests // Middleware to parse JSON requests
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Serve static files from public directory // Serve static files from public directory
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));
// Basic route for the main page // Basic route for the main page
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html')); res.sendFile(path.join(__dirname, '../public/index.html'));
}); });
// Route for overlay page with username parameter // Route for overlay page with username parameter
app.get('/overlay/:username', (req, res) => { app.get('/overlay/:username', (req, res) => {
res.sendFile(path.join(__dirname, '../public/overlay.html')); res.sendFile(path.join(__dirname, '../public/overlay.html'));
}); });
// API Routes // API Routes
const apiRoutes = require('./routes/api'); const apiRoutes = require('./routes/api');
app.use('/api', apiRoutes); app.use('/api', apiRoutes);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
status: 'OK', status: 'OK',
message: 'RetroPulse server is running', message: 'RetroPulse server is running',
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
}); });
// WebSocket Setup // WebSocket Setup
const retroClient = require('./retroClient'); const retroClient = require('./retroClient');
// Create WebSocket server // Create WebSocket server
const wss = new WebSocket.Server({ const wss = new WebSocket.Server({
server, server,
path: '/ws' path: '/ws'
}); });
// Store active connections by username // Store active connections by username
const userConnections = new Map(); const userConnections = new Map();
wss.on('connection', async (ws, req) => { wss.on('connection', async (ws, req) => {
// Extract username from query parameters // Extract username from query parameters
const url = new URL(req.url, `http://localhost:${PORT}`); const url = new URL(req.url, `http://localhost:${PORT}`);
const username = url.searchParams.get('user'); const username = url.searchParams.get('user');
if (!username) { if (!username) {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
error: 'Username parameter is required (?user=yourname)' error: 'Username parameter is required (?user=yourname)'
})); }));
ws.close(); ws.close();
return; return;
} }
console.log(`WebSocket connected for user: ${username}`);
console.log(`🔌 WebSocket connected for user: ${username}`);
// Store the connection // Store the connection
userConnections.set(username, ws); userConnections.set(username, ws);
// Send initial data immediately // Send initial data immediately
try { try {
const initialData = await retroClient.getOverlayData(username, 5); const initialData = await retroClient.getOverlayData(username, 5);
@ -86,7 +68,6 @@ wss.on('connection', async (ws, req) => {
message: `Failed to load initial data: ${error.message}` message: `Failed to load initial data: ${error.message}`
})); }));
} }
// Set up polling for updates every 10 seconds // Set up polling for updates every 10 seconds
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {
@ -94,7 +75,6 @@ wss.on('connection', async (ws, req) => {
userConnections.delete(username); userConnections.delete(username);
return; return;
} }
try { try {
const updatedData = await retroClient.getOverlayData(username, 5); const updatedData = await retroClient.getOverlayData(username, 5);
ws.send(JSON.stringify({ ws.send(JSON.stringify({
@ -105,61 +85,53 @@ wss.on('connection', async (ws, req) => {
console.error(`Error fetching updates for ${username}:`, error.message); console.error(`Error fetching updates for ${username}:`, error.message);
} }
}, 10000); // Poll every 10 seconds }, 10000); // Poll every 10 seconds
// Handle client disconnect // Handle client disconnect
ws.on('close', () => { ws.on('close', () => {
clearInterval(pollInterval); clearInterval(pollInterval);
userConnections.delete(username); userConnections.delete(username);
console.log(`🔌 WebSocket disconnected for user: ${username}`); console.log(`WebSocket disconnected for user: ${username}`);
}); });
ws.on('error', (error) => { ws.on('error', (error) => {
console.error(`WebSocket error for ${username}:`, error.message); console.error(`WebSocket error for ${username}:`, error.message);
}); });
}); });
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error('Server error:', err); console.error('Server error:', err);
res.status(500).json({ res.status(500).json({
error: 'Internal server error', error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong' message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
}); });
}); });
// Handle 404 for unmatched routes // Handle 404 for unmatched routes
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ res.status(404).json({
error: 'Not found', error: 'Not found',
message: `Route ${req.method} ${req.path} not found` message: `Route ${req.method} ${req.path} not found`
}); });
}); });
// Start the server (HTTP and WebSocket together) // Start the server (HTTP and WebSocket together)
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`🎮 RetroPulse server is running on http://localhost:${PORT}`); console.log(`RetroPulse server is running on http://localhost:${PORT}`);
console.log(`📊 Health check: http://localhost:${PORT}/health`); console.log(`Health check: http://localhost:${PORT}/health`);
console.log(`🎯 Main page: http://localhost:${PORT}/`); console.log(`Main page: http://localhost:${PORT}/`);
console.log(`📺 Overlay example: http://localhost:${PORT}/overlay/your-username`); console.log(`Overlay example: http://localhost:${PORT}/overlay/your-username`);
console.log(`🔌 WebSocket endpoint: ws://localhost:${PORT}/ws?user=your-username`); console.log(`WebSocket endpoint: ws://localhost:${PORT}/ws?user=your-username`);
}); });
// Graceful shutdown handling // Graceful shutdown handling
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
console.log('📴 Received SIGTERM, shutting down gracefully'); console.log('Received SIGTERM, shutting down gracefully');
server.close(() => { server.close(() => {
console.log('Server closed'); console.log('Server closed');
process.exit(0); process.exit(0);
}); });
}); });
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('📴 Received SIGINT, shutting down gracefully'); console.log('Received SIGINT, shutting down gracefully');
server.close(() => { server.close(() => {
console.log('Server closed'); console.log('Server closed');
process.exit(0); process.exit(0);
}); });
}); });
// Export server for potential testing // Export server for potential testing
module.exports = server; module.exports = server;

View file

@ -1,4 +1,12 @@
/* Overlay CSS - Designed for OBS Browser Source */ :root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--accent-blue: #4a90e2;
--accent-gold: #ffd700;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -6,237 +14,148 @@
} }
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Courier New', monospace;
background: transparent; /* Transparent for OBS */ background: var(--bg-primary);
color: #fff; color: var(--text-primary);
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 11px;
line-height: 1.2;
min-height: 100vh;
} }
.overlay-container { .overlay-container {
padding: 20px;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; background: var(--bg-primary);
} }
/* Connection Status */
.connection-status {
position: absolute;
top: 10px;
right: 10px;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
animation: pulse 2s infinite;
}
.connection-status.connecting {
background: rgba(255, 193, 7, 0.9);
color: #333;
}
.connection-status.connected {
background: rgba(40, 167, 69, 0.9);
color: white;
animation: none;
}
.connection-status.disconnected {
background: rgba(220, 53, 69, 0.9);
color: white;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Waiting for Activity Display */
.waiting-display { .waiting-display {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: rgba(30, 60, 114, 0.9); background: var(--bg-secondary);
padding: 40px; border: 2px solid var(--accent-blue);
border-radius: 12px;
text-align: center; text-align: center;
border: 2px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 80%; width: 80%;
max-width: 450px; max-width: 300px;
} }
.waiting-display h2 { .waiting-display h2 {
margin-bottom: 15px; font-size: 14px;
font-size: 1.5em; background: var(--accent-blue);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); color: var(--bg-primary);
} }
.waiting-display p { .waiting-display p {
opacity: 0.8; font-size: 11px;
line-height: 1.5; color: var(--text-secondary);
} }
/* Current Game */
.current-game { .current-game {
background: linear-gradient(135deg, rgba(30, 60, 114, 0.9), rgba(42, 82, 152, 0.9)); background: var(--bg-secondary);
padding: 20px; border: 1px solid var(--accent-blue);
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
} }
.game-header { .game-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 10px;
} }
.game-icon { .game-icon {
width: 64px; width: 48px;
height: 64px; height: 48px;
border-radius: 8px; border: 1px solid var(--accent-blue);
border: 2px solid rgba(255, 255, 255, 0.3); background: var(--bg-primary);
background: rgba(255, 255, 255, 0.1);
} }
.game-info h2 { .game-info h2 {
font-size: 1.8em; font-size: 14px;
margin-bottom: 5px; color: var(--text-primary);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
} }
.console-name { .console-name {
opacity: 0.8; font-size: 11px;
font-size: 1.1em; color: var(--text-secondary);
} }
/* Game Progress Bar */
.game-progress { .game-progress {
margin-top: 15px; border-top: 1px solid var(--accent-blue);
} }
.progress-stats { .progress-stats {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 1em; font-size: 10px;
margin-bottom: 8px;
opacity: 0.9;
font-weight: bold; font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); color: var(--text-primary);
background: var(--bg-primary);
} }
.progress-bar-container { .progress-bar-container {
background: rgba(0, 0, 0, 0.3); background: var(--bg-primary);
border-radius: 20px; border: 1px solid var(--accent-blue);
height: 12px; height: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
} }
.progress-bar-fill { .progress-bar-fill {
width: 0%; width: 0%;
height: 100%; height: 100%;
background: linear-gradient(90deg, #1e90ff, #00bfff); background: var(--accent-gold);
border-radius: 20px;
transition: width 0.5s ease-in-out;
} }
/* Achievements Container */
.achievements-container { .achievements-container {
flex: 1; flex: 1;
background: linear-gradient(135deg, rgba(40, 167, 69, 0.9), rgba(32, 134, 55, 0.9)); background: var(--bg-secondary);
padding: 20px; border: 1px solid var(--accent-blue);
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
overflow: hidden; overflow: hidden;
} }
.achievements-container h3 { .achievements-container h3 {
font-size: 1.4em; font-size: 12px;
margin-bottom: 15px;
text-align: center; text-align: center;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); background: var(--accent-blue);
border-bottom: 2px solid rgba(255, 255, 255, 0.3); color: var(--bg-primary);
padding-bottom: 10px;
} }
.achievements-list { .achievements-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; max-height: 300px;
max-height: 400px; /* Adjust as needed */
overflow-y: auto; overflow-y: auto;
padding-right: 10px;
list-style-type: none; list-style-type: none;
background: var(--bg-primary);
} }
/* Custom Scrollbar */
.achievements-list::-webkit-scrollbar { .achievements-list::-webkit-scrollbar {
width: 6px; width: 4px;
} }
.achievements-list::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1); .achievements-list::-webkit-scrollbar-track {
border-radius: 3px; background: var(--bg-primary);
} }
.achievements-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3); .achievements-list::-webkit-scrollbar-thumb {
border-radius: 3px; background: var(--accent-blue);
}
.achievements-list::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
} }
/* Achievement Item */
.achievement-item { .achievement-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
background: rgba(255, 255, 255, 0.1); background: var(--bg-primary);
padding: 15px; border-bottom: 1px solid var(--bg-secondary);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(5px);
transition: all 0.3s ease;
animation: slideIn 0.5s ease-out;
}
.achievement-item:hover {
transform: translateX(5px);
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.4);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
.achievement-badge { .achievement-badge {
width: 48px; width: 32px;
height: 48px; height: 32px;
border-radius: 50%; border: 1px solid var(--accent-gold);
border: 2px solid rgba(255, 255, 255, 0.3); background: var(--bg-secondary);
background: rgba(255, 255, 255, 0.1);
flex-shrink: 0; flex-shrink: 0;
} }
@ -247,96 +166,64 @@ body {
.achievement-title { .achievement-title {
font-weight: bold; font-weight: bold;
font-size: 1.1em; font-size: 11px;
margin-bottom: 3px; color: var(--text-primary);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.achievement-description { .achievement-description {
opacity: 0.8; font-size: 10px;
font-size: 0.9em; color: var(--text-secondary);
margin-bottom: 5px;
line-height: 1.3;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.achievement-meta { .achievement-meta {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.8em; font-size: 9px;
opacity: 0.7; color: var(--text-secondary);
} }
.achievement-points { .achievement-points {
background: rgba(255, 215, 0, 0.2); background: var(--accent-gold);
color: #FFD700; color: var(--bg-primary);
padding: 2px 6px; font-weight: bold;
border-radius: 10px;
border: 1px solid rgba(255, 215, 0, 0.3);
} }
.achievement-date {
font-style: italic;
}
/* Error Display */
.error-display { .error-display {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: rgba(220, 53, 69, 0.95); background: #4d1f1f;
padding: 30px; border: 2px solid #cc0000;
border-radius: 12px;
text-align: center; text-align: center;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
} }
.error-content h3 { .error-content h3 {
margin-bottom: 10px; font-size: 12px;
font-size: 1.3em; color: #ff6666;
} }
.error-content p {
margin-bottom: 20px; .error-content p {
opacity: 0.9; font-size: 11px;
} color: #ff9999;
.error-content button { }
background: rgba(255, 255, 255, 0.2);
color: white; .error-content button {
border: 1px solid rgba(255, 255, 255, 0.3); background: var(--bg-secondary);
padding: 10px 20px; color: var(--text-primary);
border-radius: 6px; border: 1px solid var(--accent-blue);
cursor: pointer; cursor: pointer;
transition: background 0.3s; font-size: 10px;
} font-family: inherit;
.error-content button:hover {
background: rgba(255, 255, 255, 0.3);
} }
/* Utility Classes */
.hidden { .hidden {
display: none !important; display: none !important;
} }
/* Responsive Design for Different OBS Sizes */
@media (max-width: 500px) {
.overlay-container {
padding: 10px;
}
.game-header {
flex-direction: column;
text-align: center;
}
.achievement-item {
flex-direction: column;
text-align: center;
}
}

View file

@ -1,282 +1,224 @@
/* Basic styling for RetroPulse */ /* RetroAchievements Style - Color Variables */
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--accent-blue: #4a90e2;
--accent-gold: #ffd700;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Courier New', monospace;
background: linear-gradient(135deg, #1e3c72, #2a5298); background: var(--bg-primary);
color: #fff; color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
line-height: 1.6; font-size: 12px;
line-height: 1.2;
} }
.container { .container {
max-width: 800px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
padding: 20px;
} }
header { header {
text-align: center; text-align: center;
margin-bottom: 40px; background: var(--bg-secondary);
border-bottom: 1px solid var(--accent-blue);
} }
header h1 { header h1 {
font-size: 3em; font-size: 18px;
margin-bottom: 10px; color: var(--accent-gold);
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
} }
header p { header p {
font-size: 1.2em; font-size: 11px;
opacity: 0.9; color: var(--text-secondary);
} }
/* Form Styling */
.user-input { .user-input {
background: rgba(255,255,255,0.1); background: var(--bg-secondary);
padding: 30px; border: 1px solid var(--accent-blue);
border-radius: 15px;
text-align: center;
margin-bottom: 30px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
} }
.user-input h2 { .user-input h2 {
margin-bottom: 20px; font-size: 14px;
font-size: 1.5em; text-align: center;
background: var(--accent-blue);
color: var(--bg-primary);
} }
#userForm { #userForm {
display: flex; display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
} }
#username { #username {
flex: 1; flex: 1;
max-width: 300px;
padding: 15px;
border: none; border: none;
border-radius: 8px; background: var(--bg-primary);
font-size: 1.1em; color: var(--text-primary);
background: rgba(255,255,255,0.9); font-size: 12px;
color: #333; font-family: inherit;
border-right: 1px solid var(--accent-blue);
} }
#username:focus { #username:focus {
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(255,255,255,0.3); background: var(--bg-secondary);
} }
button { button {
padding: 15px 30px; background: var(--accent-blue);
background: #28a745; color: var(--bg-primary);
color: white;
border: none; border: none;
border-radius: 8px; font-size: 12px;
font-size: 1.1em;
cursor: pointer; cursor: pointer;
transition: background 0.3s; font-family: inherit;
font-weight: bold;
} }
button:hover {
background: #218838;
}
/* Section States */
.hidden { .hidden {
display: none; display: none;
} }
.loading, .error { .loading, .error {
text-align: center; text-align: center;
padding: 40px; background: var(--bg-secondary);
background: rgba(255,255,255,0.1); border: 1px solid var(--accent-blue);
border-radius: 15px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
} }
.error { .error {
background: rgba(220,53,69,0.2); background: #4d1f1f;
border: 1px solid rgba(220,53,69,0.3); border-color: #cc0000;
color: #ff6666;
} }
.error-message { .error-message {
color: #ff6b6b; color: #ff6666;
font-size: 1.1em; font-size: 12px;
margin-bottom: 15px;
} }
/* User Info Styling */
.user-info { .user-info {
background: rgba(255,255,255,0.1); background: var(--bg-secondary);
padding: 30px; border: 1px solid var(--accent-blue);
border-radius: 15px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
} }
.user-info h2 {
.profile-card { font-size: 14px;
margin-bottom: 30px; background: var(--accent-blue);
color: var(--bg-primary);
text-align: center;
} }
.profile-header { .profile-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 10px;
margin-bottom: 20px; border-bottom: 1px solid var(--accent-blue);
} }
.avatar { .avatar {
width: 80px; width: 40px;
height: 80px; height: 40px;
border-radius: 50%; border: 1px solid var(--accent-blue);
border: 3px solid rgba(255,255,255,0.3);
} }
.profile-details h3 { .profile-details h3 {
font-size: 1.8em; font-size: 14px;
margin-bottom: 5px; color: var(--accent-gold);
} }
.motto { .motto {
font-size: 11px;
color: var(--text-secondary);
font-style: italic; font-style: italic;
opacity: 0.8;
margin-bottom: 10px;
} }
.stats { .stats {
display: flex; display: flex;
gap: 20px; gap: 10px;
margin-bottom: 10px;
} }
.stat { .stat {
background: rgba(255,255,255,0.1); background: var(--bg-primary);
padding: 8px 12px; font-size: 10px;
border-radius: 6px; border: 1px solid var(--accent-gold);
font-size: 0.9em; color: var(--accent-gold);
} }
.member-since { .member-since {
font-size: 0.9em; font-size: 10px;
opacity: 0.7; color: var(--text-secondary);
}
.last-game h3, .last-achievement h3 {
font-size: 12px;
background: var(--bg-primary);
color: var(--text-primary);
border-bottom: 1px solid var(--accent-blue);
} }
/* Game and Achievement Cards */
.game-card, .achievement-card { .game-card, .achievement-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 10px;
background: rgba(255,255,255,0.05); background: var(--bg-primary);
padding: 20px; border-bottom: 1px solid var(--bg-secondary);
border-radius: 10px;
margin-top: 15px;
} }
.game-icon, .achievement-badge { .game-icon, .achievement-badge {
width: 48px; width: 32px;
height: 48px; height: 32px;
border-radius: 8px; border: 1px solid var(--accent-blue);
border: 2px solid rgba(255,255,255,0.2);
} }
.game-details h4, .achievement-details h4 { .game-details h4, .achievement-details h4 {
font-size: 1.3em; font-size: 12px;
margin-bottom: 5px; color: var(--text-primary);
} }
.console, .description { .console, .description {
opacity: 0.8; font-size: 10px;
font-size: 0.9em; color: var(--text-secondary);
} }
.achievement-stats { .achievement-stats {
display: flex; display: flex;
gap: 15px; gap: 10px;
margin-top: 8px; font-size: 10px;
font-size: 0.9em;
} }
.points {
.points, .true-ratio { color: var(--accent-gold);
background: rgba(255,255,255,0.1); }
padding: 4px 8px; .true-ratio {
border-radius: 4px; color: var(--text-secondary);
} }
/* Overlay Controls */
.overlay-controls { .overlay-controls {
margin-top: 30px; background: var(--bg-primary);
padding: 25px; border: 1px solid var(--accent-gold);
background: rgba(40,167,69,0.1);
border-radius: 10px;
border: 1px solid rgba(40,167,69,0.3);
} }
.overlay-controls h3 { .overlay-controls h3 {
margin-bottom: 10px; font-size: 12px;
color: #28a745; background: var(--accent-gold);
color: var(--bg-primary);
text-align: center;
}
.overlay-controls p {
font-size: 10px;
color: var(--text-secondary);
} }
.overlay-url { .overlay-url {
display: flex; display: flex;
gap: 10px;
margin: 15px 0;
} }
.overlay-url input { .overlay-url input {
flex: 1; flex: 1;
padding: 10px; background: var(--bg-primary);
color: var(--text-primary);
border: none; border: none;
border-radius: 6px; font-family: inherit;
background: rgba(255,255,255,0.9); font-size: 10px;
color: #333; border-right: 1px solid var(--accent-gold);
font-family: monospace;
font-size: 0.9em;
} }
.overlay-button { .overlay-button {
display: inline-block; display: inline-block;
padding: 15px 25px; background: var(--accent-gold);
background: #17a2b8; color: var(--bg-primary);
color: white;
text-decoration: none; text-decoration: none;
border-radius: 8px;
font-weight: bold; font-weight: bold;
transition: background 0.3s; font-size: 12px;
text-align: center;
} }
.overlay-button:hover {
background: #138496;
}
/* Responsive Design */
@media (max-width: 600px) { @media (max-width: 600px) {
.container {
max-width: 100%;
}
.profile-header { .profile-header {
flex-direction: column; flex-direction: column;
text-align: center;
} }
.game-card, .achievement-card { .game-card, .achievement-card {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
} }
#userForm { #userForm {
flex-direction: column; flex-direction: column;
} }
#username {
max-width: none;
}
.overlay-url { .overlay-url {
flex-direction: column; flex-direction: column;
} }

View file

@ -1,46 +1,35 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RetroPulse - RetroAchievements Overlay</title> <title>RetroPulse - RetroAchievements Overlay</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🎮 RetroPulse</h1> <h1>RetroPulse</h1>
<p>RetroAchievements Overlay for OBS</p> <p>RetroAchievements Overlay for OBS</p>
</header> </header>
<main> <main>
<!-- Username Input Form -->
<section class="user-input"> <section class="user-input">
<h2>Enter RetroAchievements Username</h2> <h2>Enter RetroAchievements Username</h2>
<form id="userForm"> <form id="userForm">
<input <input type="text" id="username" placeholder="Enter your RA username..." required
type="text" autocomplete="off">
id="username"
placeholder="Enter your RA username..."
required
autocomplete="off"
>
<button type="submit">Get Info</button> <button type="submit">Get Info</button>
</form> </form>
</section> </section>
<!-- Loading State -->
<section class="loading hidden" id="loading"> <section class="loading hidden" id="loading">
<p>🔄 Loading user data...</p> <p>Loading user data...</p>
</section> </section>
<!-- Error Display -->
<section class="error hidden" id="error"> <section class="error hidden" id="error">
<p class="error-message"></p> <p class="error-message"></p>
<button onclick="clearError()">Try Again</button> <button onclick="clearError()">Try Again</button>
</section> </section>
<!-- User Info Display -->
<section class="user-info hidden" id="userInfo"> <section class="user-info hidden" id="userInfo">
<h2>User Profile</h2> <h2>User Profile</h2>
<div class="profile-card"> <div class="profile-card">
@ -61,8 +50,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Last Game Info -->
<div class="last-game" id="lastGameSection"> <div class="last-game" id="lastGameSection">
<h3>Currently Playing</h3> <h3>Currently Playing</h3>
<div class="game-card"> <div class="game-card">
@ -73,8 +60,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Last Achievement -->
<div class="last-achievement" id="lastAchievementSection"> <div class="last-achievement" id="lastAchievementSection">
<h3>Latest Achievement</h3> <h3>Latest Achievement</h3>
<div class="achievement-card"> <div class="achievement-card">
@ -93,8 +78,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Overlay Button -->
<div class="overlay-controls"> <div class="overlay-controls">
<h3>Overlay for OBS</h3> <h3>Overlay for OBS</h3>
<p>Use this URL as a Browser Source in OBS Studio:</p> <p>Use this URL as a Browser Source in OBS Studio:</p>
@ -103,13 +86,13 @@
<button onclick="copyOverlayUrl()">Copy URL</button> <button onclick="copyOverlayUrl()">Copy URL</button>
</div> </div>
<a id="overlayLink" href="#" target="_blank" class="overlay-button"> <a id="overlayLink" href="#" target="_blank" class="overlay-button">
📺 Open Overlay Open Overlay
</a> </a>
</div> </div>
</section> </section>
</main> </main>
</div> </div>
<script src="/js/main.js"></script> <script src="/js/main.js"></script>
</body> </body>
</html>
</html>

View file

@ -1,88 +1,68 @@
// main.js - Frontend logic for the main page
let currentUsername = ''; let currentUsername = '';
// DOM elements // DOM elements
const userForm = document.getElementById('userForm'); const userForm = document.getElementById('userForm');
const usernameInput = document.getElementById('username'); const usernameInput = document.getElementById('username');
const loadingSection = document.getElementById('loading'); const loadingSection = document.getElementById('loading');
const errorSection = document.getElementById('error'); const errorSection = document.getElementById('error');
const userInfoSection = document.getElementById('userInfo'); const userInfoSection = document.getElementById('userInfo');
// Initialize event listeners // Initialize event listeners
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
userForm.addEventListener('submit', handleFormSubmit); userForm.addEventListener('submit', handleFormSubmit);
// Auto-focus username input // Auto-focus username input
usernameInput.focus(); usernameInput.focus();
// Allow Enter key to submit // Allow Enter key to submit
usernameInput.addEventListener('keypress', function(e) { usernameInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleFormSubmit(e); handleFormSubmit(e);
} }
}); });
}); });
// Handle form submission // Handle form submission
async function handleFormSubmit(e) { async function handleFormSubmit(e) {
e.preventDefault(); e.preventDefault();
const username = usernameInput.value.trim(); const username = usernameInput.value.trim();
if (!username) { if (!username) {
showError('Please enter a username'); showError('Please enter a username');
return; return;
} }
// Validate username format // Validate username format
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
showError('Username can only contain letters, numbers, underscores, and hyphens'); showError('Username can only contain letters, numbers, underscores, and hyphens');
return; return;
} }
currentUsername = username; currentUsername = username;
await fetchUserData(username); await fetchUserData(username);
} }
// Fetch user data from API // Fetch user data from API
async function fetchUserData(username) { async function fetchUserData(username) {
showLoading(); showLoading();
try { try {
console.log(`Fetching data for user: ${username}`); console.log(`Fetching data for user: ${username}`);
const response = await fetch(`/api/userinfo?username=${encodeURIComponent(username)}`); const response = await fetch(`/api/userinfo?username=${encodeURIComponent(username)}`);
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || 'Failed to fetch user data'); throw new Error(data.message || 'Failed to fetch user data');
} }
console.log('User data received:', data); console.log('User data received:', data);
displayUserData(data); displayUserData(data);
} catch (error) { } catch (error) {
console.error('Error fetching user data:', error); console.error('Error fetching user data:', error);
showError(error.message || 'Failed to load user data. Please try again.'); showError(error.message || 'Failed to load user data. Please try again.');
} }
} }
// Display user data in the UI // Display user data in the UI
function displayUserData(data) { function displayUserData(data) {
const { user, lastGame, lastAchievement } = data; const { user, lastGame, lastAchievement } = data;
// Update user profile // Update user profile
document.getElementById('userName').textContent = user.displayName; document.getElementById('userName').textContent = user.displayName;
document.getElementById('userMotto').textContent = user.motto || 'No motto set'; document.getElementById('userMotto').textContent = user.motto || 'No motto set';
document.getElementById('totalPoints').textContent = user.totalPoints.toLocaleString(); document.getElementById('totalPoints').textContent = user.totalPoints.toLocaleString();
document.getElementById('truePoints').textContent = user.totalTruePoints.toLocaleString(); document.getElementById('truePoints').textContent = user.totalTruePoints.toLocaleString();
// Format member since date // Format member since date
if (user.memberSince) { if (user.memberSince) {
const memberDate = new Date(user.memberSince); const memberDate = new Date(user.memberSince);
document.getElementById('memberSince').textContent = memberDate.toLocaleDateString(); document.getElementById('memberSince').textContent = memberDate.toLocaleDateString();
} }
// Set avatar // Set avatar
const avatarImg = document.getElementById('userAvatar'); const avatarImg = document.getElementById('userAvatar');
if (user.avatar) { if (user.avatar) {
@ -91,13 +71,11 @@ function displayUserData(data) {
} else { } else {
avatarImg.style.display = 'none'; avatarImg.style.display = 'none';
} }
// Display last game // Display last game
const lastGameSection = document.getElementById('lastGameSection'); const lastGameSection = document.getElementById('lastGameSection');
if (lastGame) { if (lastGame) {
document.getElementById('gameTitle').textContent = lastGame.title; document.getElementById('gameTitle').textContent = lastGame.title;
document.getElementById('consoleName').textContent = lastGame.consoleName; document.getElementById('consoleName').textContent = lastGame.consoleName;
const gameIconImg = document.getElementById('gameIcon'); const gameIconImg = document.getElementById('gameIcon');
if (lastGame.icon) { if (lastGame.icon) {
gameIconImg.src = lastGame.icon; gameIconImg.src = lastGame.icon;
@ -109,7 +87,6 @@ function displayUserData(data) {
} else { } else {
lastGameSection.style.display = 'none'; lastGameSection.style.display = 'none';
} }
// Display last achievement // Display last achievement
const lastAchievementSection = document.getElementById('lastAchievementSection'); const lastAchievementSection = document.getElementById('lastAchievementSection');
if (lastAchievement) { if (lastAchievement) {
@ -117,7 +94,6 @@ function displayUserData(data) {
document.getElementById('achievementDescription').textContent = lastAchievement.description; document.getElementById('achievementDescription').textContent = lastAchievement.description;
document.getElementById('achievementPoints').textContent = lastAchievement.points; document.getElementById('achievementPoints').textContent = lastAchievement.points;
document.getElementById('achievementTrueRatio').textContent = lastAchievement.trueRatio; document.getElementById('achievementTrueRatio').textContent = lastAchievement.trueRatio;
// Set achievement badge (try badgeName first, then gameIcon as fallback) // Set achievement badge (try badgeName first, then gameIcon as fallback)
const achievementBadgeImg = document.getElementById('achievementBadge'); const achievementBadgeImg = document.getElementById('achievementBadge');
// For now, we'll use the game icon since badges aren't in the current API response // For now, we'll use the game icon since badges aren't in the current API response
@ -131,64 +107,52 @@ function displayUserData(data) {
} else { } else {
lastAchievementSection.style.display = 'none'; lastAchievementSection.style.display = 'none';
} }
// Setup overlay URL and link // Setup overlay URL and link
const overlayUrl = `${window.location.origin}/overlay/${encodeURIComponent(currentUsername)}`; const overlayUrl = `${window.location.origin}/overlay/${encodeURIComponent(currentUsername)}`;
document.getElementById('overlayUrl').value = overlayUrl; document.getElementById('overlayUrl').value = overlayUrl;
document.getElementById('overlayLink').href = overlayUrl; document.getElementById('overlayLink').href = overlayUrl;
showUserInfo(); showUserInfo();
} }
// UI state management functions // UI state management functions
function showLoading() { function showLoading() {
hideAllSections(); hideAllSections();
loadingSection.classList.remove('hidden'); loadingSection.classList.remove('hidden');
} }
function showError(message) { function showError(message) {
hideAllSections(); hideAllSections();
document.querySelector('.error-message').textContent = message; document.querySelector('.error-message').textContent = message;
errorSection.classList.remove('hidden'); errorSection.classList.remove('hidden');
} }
function showUserInfo() { function showUserInfo() {
hideAllSections(); hideAllSections();
userInfoSection.classList.remove('hidden'); userInfoSection.classList.remove('hidden');
} }
function hideAllSections() { function hideAllSections() {
loadingSection.classList.add('hidden'); loadingSection.classList.add('hidden');
errorSection.classList.add('hidden'); errorSection.classList.add('hidden');
userInfoSection.classList.add('hidden'); userInfoSection.classList.add('hidden');
} }
function clearError() { function clearError() {
showUserInfo(); showUserInfo();
} }
// Copy overlay URL to clipboard // Copy overlay URL to clipboard
async function copyOverlayUrl() { async function copyOverlayUrl() {
const overlayUrlInput = document.getElementById('overlayUrl'); const overlayUrlInput = document.getElementById('overlayUrl');
try { try {
await navigator.clipboard.writeText(overlayUrlInput.value); await navigator.clipboard.writeText(overlayUrlInput.value);
// Show feedback // Show feedback
const button = event.target; const button = event.target;
const originalText = button.textContent; const originalText = button.textContent;
button.textContent = 'Copied!'; button.textContent = 'Copied!';
button.style.background = '#28a745'; button.style.background = '#28a745';
setTimeout(() => { setTimeout(() => {
button.textContent = originalText; button.textContent = originalText;
button.style.background = ''; button.style.background = '';
}, 2000); }, 2000);
} catch (err) { } catch (err) {
// Fallback for older browsers // Fallback for older browsers
overlayUrlInput.select(); overlayUrlInput.select();
document.execCommand('copy'); document.execCommand('copy');
alert('Overlay URL copied to clipboard!'); alert('Overlay URL copied to clipboard!');
} }
} }

View file

@ -1,4 +1,3 @@
// overlay.js - WebSocket client for real-time achievement updates
class RetroPulseOverlay { class RetroPulseOverlay {
constructor() { constructor() {
this.ws = null; this.ws = null;
@ -6,66 +5,50 @@ class RetroPulseOverlay {
this.reconnectInterval = null; this.reconnectInterval = null;
this.reconnectDelay = 3000; // 3 seconds this.reconnectDelay = 3000; // 3 seconds
this.lastUpdateTime = null; this.lastUpdateTime = null;
// DOM elements // DOM elements
this.connectionStatus = document.getElementById('connection-status');
this.errorDisplay = document.getElementById('error-display'); this.errorDisplay = document.getElementById('error-display');
this.errorMessage = document.getElementById('error-message'); this.errorMessage = document.getElementById('error-message');
this.waitingForActivity = document.getElementById('waiting-for-activity'); this.waitingForActivity = document.getElementById('waiting-for-activity');
// Game elements // Game elements
this.currentGame = document.getElementById('current-game'); this.currentGame = document.getElementById('current-game');
this.gameIcon = document.getElementById('game-icon'); this.gameIcon = document.getElementById('game-icon');
this.gameTitle = document.getElementById('game-title'); this.gameTitle = document.getElementById('game-title');
this.consoleName = document.getElementById('console-name'); this.consoleName = document.getElementById('console-name');
// Progress bar elements // Progress bar elements
this.gameProgressSection = document.getElementById('game-progress-section'); this.gameProgressSection = document.getElementById('game-progress-section');
this.progressText = document.getElementById('progress-text'); this.progressText = document.getElementById('progress-text');
this.progressPercentage = document.getElementById('progress-percentage'); this.progressPercentage = document.getElementById('progress-percentage');
this.progressBar = document.getElementById('progress-bar'); this.progressBar = document.getElementById('progress-bar');
// Achievements elements // Achievements elements
this.achievementsContainer = document.getElementById('achievements-container'); this.achievementsContainer = document.getElementById('achievements-container');
this.achievementsList = document.getElementById('achievements-list'); this.achievementsList = document.getElementById('achievements-list');
this.init(); this.init();
} }
init() { init() {
// Extract username from URL path // Extract username from URL path
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
this.username = decodeURIComponent(pathParts[pathParts.length - 1]); this.username = decodeURIComponent(pathParts[pathParts.length - 1]);
if (!this.username || this.username === 'overlay') { if (!this.username || this.username === 'overlay') {
this.showError('No username specified in URL'); this.showError('No username specified in URL');
return; return;
} }
console.log(`Initializing RetroPulse overlay for user: ${this.username}`);
console.log(`🎮 Initializing RetroPulse overlay for user: ${this.username}`);
this.connect(); this.connect();
} }
connect() { connect() {
try { try {
// Determine WebSocket URL (ws:// for http, wss:// for https) // Determine WebSocket URL (ws:// for http, wss:// for https)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws?user=${encodeURIComponent(this.username)}`; const wsUrl = `${protocol}//${window.location.host}/ws?user=${encodeURIComponent(this.username)}`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
console.log(`🔌 Connecting to WebSocket: ${wsUrl}`);
this.updateConnectionStatus('connecting');
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('✅ WebSocket connected'); console.log('WebSocket connected');
this.updateConnectionStatus('connected');
if (this.reconnectInterval) { if (this.reconnectInterval) {
clearInterval(this.reconnectInterval); clearInterval(this.reconnectInterval);
this.reconnectInterval = null; this.reconnectInterval = null;
} }
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
@ -74,38 +57,30 @@ class RetroPulseOverlay {
console.error('Error parsing WebSocket message:', error); console.error('Error parsing WebSocket message:', error);
} }
}; };
this.ws.onclose = (event) => { this.ws.onclose = (event) => {
console.log('❌ WebSocket disconnected:', event.code, event.reason); console.log('WebSocket disconnected:', event.code, event.reason);
this.updateConnectionStatus('disconnected');
if (event.code !== 1000) { // Don't reconnect on clean close if (event.code !== 1000) { // Don't reconnect on clean close
this.scheduleReconnect(); this.scheduleReconnect();
} }
}; };
this.ws.onerror = (error) => { this.ws.onerror = (error) => {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
this.updateConnectionStatus('disconnected');
// The onclose event will fire next, which will handle reconnecting.
}; };
} catch (error) { } catch (error) {
console.error('Error creating WebSocket connection:', error); console.error('Error creating WebSocket connection:', error);
this.showError(`Connection failed: ${error.message}`); this.showError(`Connection failed: ${error.message}`);
} }
} }
scheduleReconnect() { scheduleReconnect() {
if (this.reconnectInterval) return; // Reconnect already scheduled if (this.reconnectInterval) return; // Reconnect already scheduled
console.log(`⏱️ Scheduling reconnect in ${this.reconnectDelay / 1000} seconds...`); console.log(`Scheduling reconnect in ${this.reconnectDelay / 1000} seconds...`);
this.reconnectInterval = setInterval(() => { this.reconnectInterval = setInterval(() => {
console.log('Reconnecting...'); console.log('Reconnecting...');
this.connect(); this.connect();
}, this.reconnectDelay); }, this.reconnectDelay);
} }
handleMessage(message) { handleMessage(message) {
console.log('📨 Received message:', message); console.log('Received message:', message);
if (message.error) { if (message.error) {
this.showError(message.error); this.showError(message.error);
return; return;
@ -114,21 +89,16 @@ class RetroPulseOverlay {
this.updateOverlay(message.data); this.updateOverlay(message.data);
} }
} }
updateOverlay(data) { updateOverlay(data) {
if (!data) return; if (!data) return;
console.log('🔄 Updating overlay with data:', data); console.log('Updating overlay with data:', data);
this.lastUpdateTime = new Date(); this.lastUpdateTime = new Date();
this.errorDisplay.classList.add('hidden'); this.errorDisplay.classList.add('hidden');
const hasGameProgress = data.gameProgress !== null; const hasGameProgress = data.gameProgress !== null;
const hasAchievements = data.recentAchievements && data.recentAchievements.length > 0; const hasAchievements = data.recentAchievements && data.recentAchievements.length > 0;
if (hasGameProgress || hasAchievements) { if (hasGameProgress || hasAchievements) {
// We have data, so hide the waiting message // We have data, so hide the waiting message
this.waitingForActivity.classList.add('hidden'); this.waitingForActivity.classList.add('hidden');
// Update Game Info and Progress Bar // Update Game Info and Progress Bar
if (hasGameProgress) { if (hasGameProgress) {
this.currentGame.classList.remove('hidden'); this.currentGame.classList.remove('hidden');
@ -136,7 +106,6 @@ class RetroPulseOverlay {
} else { } else {
this.currentGame.classList.add('hidden'); this.currentGame.classList.add('hidden');
} }
// Update achievements list // Update achievements list
if (hasAchievements) { if (hasAchievements) {
this.achievementsContainer.classList.remove('hidden'); this.achievementsContainer.classList.remove('hidden');
@ -151,20 +120,16 @@ class RetroPulseOverlay {
this.achievementsContainer.classList.add('hidden'); this.achievementsContainer.classList.add('hidden');
} }
} }
updateGameInfo(gameData) { updateGameInfo(gameData) {
this.currentGame.classList.remove('hidden'); this.currentGame.classList.remove('hidden');
this.gameTitle.textContent = gameData.title; this.gameTitle.textContent = gameData.title;
this.consoleName.textContent = gameData.consoleName || ''; this.consoleName.textContent = gameData.consoleName || '';
if (gameData.icon) {
if(gameData.icon) {
this.gameIcon.src = gameData.icon; this.gameIcon.src = gameData.icon;
this.gameIcon.style.display = 'block'; this.gameIcon.style.display = 'block';
} else { } else {
this.gameIcon.style.display = 'none'; this.gameIcon.style.display = 'none';
} }
// Update progress bar // Update progress bar
if (gameData.hasOwnProperty('completion') && gameData.totalAchievements > 0) { if (gameData.hasOwnProperty('completion') && gameData.totalAchievements > 0) {
this.progressText.textContent = `Completion: ${gameData.numAwarded} / ${gameData.totalAchievements}`; this.progressText.textContent = `Completion: ${gameData.numAwarded} / ${gameData.totalAchievements}`;
@ -175,58 +140,44 @@ class RetroPulseOverlay {
this.gameProgressSection.classList.add('hidden'); this.gameProgressSection.classList.add('hidden');
} }
} }
updateAchievements(achievements) { updateAchievements(achievements) {
// To avoid flickering, only update if content has changed. // To avoid flickering, only update if content has changed.
// A simple check on the first achievement ID can work. // A simple check on the first achievement ID can work.
const firstExisting = this.achievementsList.querySelector('.achievement-item'); const firstExisting = this.achievementsList.querySelector('.achievement-item');
if (firstExisting && firstExisting.dataset.id == achievements[0].id) { if (firstExisting && firstExisting.dataset.id == achievements[0].id) {
console.log('🧘 Achievements are already up-to-date.'); console.log('Achievements are already up-to-date.');
return; return;
} }
console.log('Updating achievements list.');
console.log('✨ Updating achievements list.');
this.achievementsList.innerHTML = ''; // Clear existing this.achievementsList.innerHTML = ''; // Clear existing
achievements.forEach(ach => { achievements.forEach(ach => {
const item = document.createElement('li'); const item = document.createElement('li');
item.className = 'achievement-item'; item.className = 'achievement-item';
item.dataset.id = ach.id; // For update checking item.dataset.id = ach.id; // For update checking
const earnedDate = new Date(ach.dateEarned).toLocaleString();
item.innerHTML = ` item.innerHTML = `
<img src="${ach.badgeName}" alt="Badge" class="achievement-badge"> <div class="achievement-badge">
<div class="achievement-details"> <img src="${ach.badgeName || ach.gameIcon || ''}" alt="Achievement Badge" style="width: 100%; height: 100%; object-fit: cover;">
<div class="achievement-title">${ach.title}</div> </div>
<div class="achievement-description">${ach.description}</div> <div class="achievement-details">
<div class="achievement-meta"> <div class="achievement-title">${ach.title}</div>
<span class="achievement-points">${ach.points} Pts</span> <div class="achievement-description">${ach.description}</div>
<span class="achievement-date">${earnedDate}</span> <div class="achievement-meta">
</div> <span class="achievement-points">${ach.points}pts</span>
</div> </div>
`; </div>
`;
this.achievementsList.appendChild(item); this.achievementsList.appendChild(item);
}); });
} }
updateConnectionStatus(status) {
this.connectionStatus.className = 'connection-status'; // Reset classes
this.connectionStatus.classList.add(status);
this.connectionStatus.textContent = status;
}
showError(message) { showError(message) {
console.error('Overlay Error:', message);
this.errorMessage.textContent = message; this.errorMessage.textContent = message;
this.errorDisplay.classList.remove('hidden'); this.errorDisplay.classList.remove('hidden');
// Hide other elements this.waitingForActivity.classList.add('hidden');
this.currentGame.classList.add('hidden'); this.currentGame.classList.add('hidden');
this.achievementsContainer.classList.add('hidden'); this.achievementsContainer.classList.add('hidden');
this.waitingForActivity.classList.add('hidden');
} }
} }
// Initialize the overlay when DOM is loaded
// Initialize the overlay logic
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
new RetroPulseOverlay(); new RetroPulseOverlay();
}); });

View file

@ -1,23 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RetroPulse Overlay</title> <title>RetroPulse Overlay</title>
<link rel="stylesheet" href="/css/overlay.css"> <link rel="stylesheet" href="/css/overlay.css">
</head> </head>
<body> <body>
<div id="overlay-container" class="overlay-container"> <div id="overlay-container" class="overlay-container">
<!-- Connection Status -->
<div id="connection-status" class="connection-status connecting">Connecting...</div>
<!-- Waiting for Activity -->
<section id="waiting-for-activity" class="waiting-display hidden"> <section id="waiting-for-activity" class="waiting-display hidden">
<h2>Waiting for Game Activity</h2> <h2>Waiting for Game Activity</h2>
<p>Start playing a game with RetroAchievements enabled. New achievements will appear here automatically.</p> <p>Start playing a game with RetroAchievements enabled. New achievements will appear here automatically.</p>
</section> </section>
<!-- Current Game -->
<section id="current-game" class="current-game hidden"> <section id="current-game" class="current-game hidden">
<div class="game-header"> <div class="game-header">
<img id="game-icon" src="" alt="Game Icon" class="game-icon"> <img id="game-icon" src="" alt="Game Icon" class="game-icon">
@ -26,7 +22,6 @@
<p id="console-name" class="console-name"></p> <p id="console-name" class="console-name"></p>
</div> </div>
</div> </div>
<!-- Game Progress Bar -->
<div id="game-progress-section" class="game-progress hidden"> <div id="game-progress-section" class="game-progress hidden">
<div class="progress-stats"> <div class="progress-stats">
<span id="progress-text">Completion: 0/0</span> <span id="progress-text">Completion: 0/0</span>
@ -37,16 +32,11 @@
</div> </div>
</div> </div>
</section> </section>
<!-- Achievements -->
<section id="achievements-container" class="achievements-container hidden"> <section id="achievements-container" class="achievements-container hidden">
<h3>Recent Achievements</h3> <h3>Recent Achievements</h3>
<ul id="achievements-list" class="achievements-list"> <ul id="achievements-list" class="achievements-list">
<!-- Achievement items will be injected here -->
</ul> </ul>
</section> </section>
<!-- Error Display -->
<div id="error-display" class="error-display hidden"> <div id="error-display" class="error-display hidden">
<div class="error-content"> <div class="error-content">
<h3>Error</h3> <h3>Error</h3>
@ -55,7 +45,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/js/overlay.js"></script> <script src="/js/overlay.js"></script>
</body> </body>
</html>
</html>