UI overhaul
This commit is contained in:
parent
ca5a55a612
commit
e0563ba5e4
|
@ -1,7 +1,4 @@
|
|||
// backend/retroClient.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
buildAuthorization,
|
||||
getUserProfile,
|
||||
|
@ -9,35 +6,27 @@ const {
|
|||
getGame,
|
||||
getGameInfoAndUserProgress
|
||||
} = require('@retroachievements/api');
|
||||
|
||||
class RetroClient {
|
||||
|
||||
constructor() {
|
||||
const RA_USER = process.env.RA_USER;
|
||||
const RA_KEY = process.env.RA_KEY;
|
||||
|
||||
if (!RA_USER || !RA_KEY) {
|
||||
throw new Error('RetroAchievements username and API key must be set in .env file');
|
||||
}
|
||||
|
||||
this.authorization = buildAuthorization({
|
||||
username: RA_USER,
|
||||
webApiKey: RA_KEY
|
||||
});
|
||||
console.log('🎮 RetroAchievements API client initialized');
|
||||
console.log('RetroAchievements API client initialized');
|
||||
}
|
||||
|
||||
// Helper method to build correct image URLs
|
||||
buildImageUrl(imagePath, baseUrl) {
|
||||
if (!imagePath) return null;
|
||||
|
||||
const toStr = (v) => (typeof v === 'string' ? v : String(v));
|
||||
|
||||
// If full URL provided, return as-is
|
||||
if (/^https?:\/\//i.test(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
const pathStr = toStr(imagePath);
|
||||
// Handle full server-relative paths that start with /
|
||||
if (pathStr.startsWith('/')) {
|
||||
|
@ -48,84 +37,74 @@ class RetroClient {
|
|||
}
|
||||
return `https://media.retroachievements.org${pathStr}`;
|
||||
}
|
||||
|
||||
// Normalize base URL (strip trailing slashes)
|
||||
const normalizedBase = toStr(baseUrl || '').replace(/\/+$/, '');
|
||||
|
||||
// Special handling for badges - always ensure .png extension
|
||||
const isBadgeContext = /\/Badge(\/|$)/i.test(normalizedBase);
|
||||
if (isBadgeContext) {
|
||||
const cleanImagePath = pathStr.replace(/\.(png|jpe?g|gif|webp)$/i, '');
|
||||
return `${normalizedBase}/${cleanImagePath}.png`;
|
||||
}
|
||||
|
||||
// For other images (UserPic, Images), build normally
|
||||
return `${normalizedBase}/${pathStr}`;
|
||||
}
|
||||
|
||||
async getUserProfile(username) {
|
||||
try {
|
||||
console.log(`📊 Fetching profile for user: ${username}`);
|
||||
console.log(`Fetching profile for user: ${username}`);
|
||||
const profile = await getUserProfile(this.authorization, {
|
||||
username
|
||||
});
|
||||
return profile;
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserRecentAchievements(username, count = 10) {
|
||||
try {
|
||||
console.log(`🏆 Fetching recent achievements for user: ${username}`);
|
||||
console.log(`Fetching recent achievements for user: ${username}`);
|
||||
const achievements = await getUserRecentAchievements(this.authorization, {
|
||||
username,
|
||||
count
|
||||
});
|
||||
return achievements;
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getGameInfo(gameId) {
|
||||
try {
|
||||
console.log(`🎮 Fetching game info for ID: ${gameId}`);
|
||||
console.log(`Fetching game info for ID: ${gameId}`);
|
||||
const game = await getGame(this.authorization, {
|
||||
gameId
|
||||
});
|
||||
return game;
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserData(username) {
|
||||
try {
|
||||
const profile = await this.getUserProfile(username);
|
||||
let lastGame = null;
|
||||
let lastAchievement = null;
|
||||
|
||||
if (profile.lastGameId) {
|
||||
try {
|
||||
lastGame = await this.getGameInfo(profile.lastGameId);
|
||||
} catch (gameError) {
|
||||
console.warn(`⚠️ Could not fetch last game info: ${gameError.message}`);
|
||||
console.warn(`Could not fetch last game info: ${gameError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const recentAchievements = await this.getUserRecentAchievements(username, 1);
|
||||
if (recentAchievements && recentAchievements.length > 0) {
|
||||
lastAchievement = recentAchievements[0];
|
||||
}
|
||||
} catch (achievementError) {
|
||||
console.warn(`⚠️ Could not fetch recent achievements: ${achievementError.message}`);
|
||||
console.warn(`Could not fetch recent achievements: ${achievementError.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
username: profile.user,
|
||||
|
@ -140,65 +119,60 @@ class RetroClient {
|
|||
memberSince: profile.memberSince || null,
|
||||
rank: profile.rank || null
|
||||
},
|
||||
lastGame: lastGame ?
|
||||
{
|
||||
id: lastGame.gameId,
|
||||
title: lastGame.title,
|
||||
icon: this.buildImageUrl(
|
||||
lastGame.gameIcon,
|
||||
'https://media.retroachievements.org/Images'
|
||||
),
|
||||
consoleName: lastGame.consoleName
|
||||
} :
|
||||
lastGame: lastGame ? {
|
||||
id: lastGame.gameId,
|
||||
title: lastGame.title,
|
||||
icon: this.buildImageUrl(
|
||||
lastGame.gameIcon,
|
||||
'https://media.retroachievements.org/Images'
|
||||
),
|
||||
consoleName: lastGame.consoleName
|
||||
} :
|
||||
null,
|
||||
lastAchievement: lastAchievement ?
|
||||
{
|
||||
id: lastAchievement.achievementId,
|
||||
title: lastAchievement.title,
|
||||
description: lastAchievement.description,
|
||||
points: lastAchievement.points,
|
||||
trueRatio: lastAchievement.trueRatio,
|
||||
gameTitle: lastAchievement.gameTitle,
|
||||
gameIcon: this.buildImageUrl(
|
||||
lastAchievement.gameIcon,
|
||||
'https://media.retroachievements.org/Images'
|
||||
),
|
||||
badgeName: this.buildImageUrl(
|
||||
lastAchievement.badgeName,
|
||||
'https://media.retroachievements.org/Badge'
|
||||
),
|
||||
dateEarned: lastAchievement.dateEarned
|
||||
} :
|
||||
lastAchievement: lastAchievement ? {
|
||||
id: lastAchievement.achievementId,
|
||||
title: lastAchievement.title,
|
||||
description: lastAchievement.description,
|
||||
points: lastAchievement.points,
|
||||
trueRatio: lastAchievement.trueRatio,
|
||||
gameTitle: lastAchievement.gameTitle,
|
||||
gameIcon: this.buildImageUrl(
|
||||
lastAchievement.gameIcon,
|
||||
'https://media.retroachievements.org/Images'
|
||||
),
|
||||
badgeName: this.buildImageUrl(
|
||||
lastAchievement.badgeName,
|
||||
'https://media.retroachievements.org/Badge'
|
||||
),
|
||||
dateEarned: lastAchievement.dateEarned
|
||||
} :
|
||||
null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`❌ Error in getUserData for ${username}:`, error.message);
|
||||
console.error(`Error in getUserData for ${username}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getOverlayData(username, count = 5) {
|
||||
try {
|
||||
const recentAchievements = await this.getUserRecentAchievements(username, count);
|
||||
let gameProgress = null;
|
||||
let gameId = null;
|
||||
|
||||
// First, try to get gameId from the most recent achievement. This is the most "live" data.
|
||||
if (recentAchievements && recentAchievements.length > 0) {
|
||||
gameId = recentAchievements[0].gameId;
|
||||
} else {
|
||||
// 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 {
|
||||
const profile = await this.getUserProfile(username);
|
||||
if (profile.lastGameId) {
|
||||
gameId = profile.lastGameId;
|
||||
}
|
||||
} 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 (gameId) {
|
||||
try {
|
||||
|
@ -206,7 +180,6 @@ class RetroClient {
|
|||
username,
|
||||
gameId
|
||||
});
|
||||
|
||||
gameProgress = {
|
||||
title: progressData.title,
|
||||
icon: this.buildImageUrl(progressData.imageIcon, 'https://media.retroachievements.org/Images'),
|
||||
|
@ -216,10 +189,9 @@ class RetroClient {
|
|||
completion: progressData.userCompletion,
|
||||
};
|
||||
} 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) => {
|
||||
return {
|
||||
id: achievement.achievementId,
|
||||
|
@ -239,7 +211,6 @@ class RetroClient {
|
|||
dateEarned: achievement.dateEarned
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
username,
|
||||
gameProgress,
|
||||
|
@ -247,10 +218,9 @@ class RetroClient {
|
|||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`❌ Error in getOverlayData for ${username}:`, error.message);
|
||||
console.error(`Error in getOverlayData for ${username}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RetroClient();
|
|
@ -1,19 +1,16 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const retroClient = require('../retroClient');
|
||||
|
||||
// GET /api/userinfo?username=xxx
|
||||
router.get('/userinfo', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.query;
|
||||
|
||||
if (!username) {
|
||||
return res.status(400).json({
|
||||
error: 'Username is required',
|
||||
message: 'Please provide a username parameter'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate username (basic sanitization)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📡 API request for user: ${username}`);
|
||||
|
||||
console.log(`API request for user: ${username}`);
|
||||
// Fetch real user data from RetroAchievements
|
||||
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);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in /api/userinfo:', error);
|
||||
|
||||
console.error('Error in /api/userinfo:', error);
|
||||
// Handle specific API errors
|
||||
if (error.message.includes('User not found') || error.message.includes('Invalid user')) {
|
||||
return res.status(404).json({
|
||||
|
@ -40,7 +32,6 @@ router.get('/userinfo', async (req, res) => {
|
|||
message: `RetroAchievements user '${req.query.username}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
// Handle rate limiting
|
||||
if (error.message.includes('rate limit') || error.message.includes('too many requests')) {
|
||||
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.'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch user information',
|
||||
message: 'An error occurred while fetching data from RetroAchievements'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/achievements/:username - For WebSocket data
|
||||
router.get('/achievements/:username', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const { count = 5 } = req.query;
|
||||
|
||||
// Validate username
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate count parameter
|
||||
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
|
||||
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);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in /api/achievements:', error);
|
||||
|
||||
console.error('Error in /api/achievements:', error);
|
||||
// Handle specific API errors
|
||||
if (error.message.includes('User not found') || error.message.includes('Invalid user')) {
|
||||
return res.status(404).json({
|
||||
|
@ -91,14 +73,12 @@ router.get('/achievements/:username', async (req, res) => {
|
|||
message: `RetroAchievements user '${req.params.username}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch achievements',
|
||||
message: 'An error occurred while fetching achievements from RetroAchievements'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/health - Enhanced health check that tests RA API connection
|
||||
router.get('/health', async (req, res) => {
|
||||
try {
|
||||
|
@ -118,5 +98,4 @@ router.get('/health', async (req, res) => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -1,39 +1,29 @@
|
|||
// backend/server.js
|
||||
|
||||
// Load environment variables from .env file
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const path = require('path');
|
||||
|
||||
// Create Express app and HTTP server
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware to parse JSON requests
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Basic route for the main page
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// Route for overlay page with username parameter
|
||||
app.get('/overlay/:username', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/overlay.html'));
|
||||
});
|
||||
|
||||
// API Routes
|
||||
const apiRoutes = require('./routes/api');
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
|
@ -42,24 +32,19 @@ app.get('/health', (req, res) => {
|
|||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket Setup
|
||||
const retroClient = require('./retroClient');
|
||||
|
||||
// Create WebSocket server
|
||||
const wss = new WebSocket.Server({
|
||||
server,
|
||||
path: '/ws'
|
||||
});
|
||||
|
||||
// Store active connections by username
|
||||
const userConnections = new Map();
|
||||
|
||||
wss.on('connection', async (ws, req) => {
|
||||
// Extract username from query parameters
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const username = url.searchParams.get('user');
|
||||
|
||||
if (!username) {
|
||||
ws.send(JSON.stringify({
|
||||
error: 'Username parameter is required (?user=yourname)'
|
||||
|
@ -67,12 +52,9 @@ wss.on('connection', async (ws, req) => {
|
|||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔌 WebSocket connected for user: ${username}`);
|
||||
|
||||
console.log(`WebSocket connected for user: ${username}`);
|
||||
// Store the connection
|
||||
userConnections.set(username, ws);
|
||||
|
||||
// Send initial data immediately
|
||||
try {
|
||||
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}`
|
||||
}));
|
||||
}
|
||||
|
||||
// Set up polling for updates every 10 seconds
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
|
@ -94,7 +75,6 @@ wss.on('connection', async (ws, req) => {
|
|||
userConnections.delete(username);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedData = await retroClient.getOverlayData(username, 5);
|
||||
ws.send(JSON.stringify({
|
||||
|
@ -105,19 +85,16 @@ wss.on('connection', async (ws, req) => {
|
|||
console.error(`Error fetching updates for ${username}:`, error.message);
|
||||
}
|
||||
}, 10000); // Poll every 10 seconds
|
||||
|
||||
// Handle client disconnect
|
||||
ws.on('close', () => {
|
||||
clearInterval(pollInterval);
|
||||
userConnections.delete(username);
|
||||
console.log(`🔌 WebSocket disconnected for user: ${username}`);
|
||||
console.log(`WebSocket disconnected for user: ${username}`);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error for ${username}:`, error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Server error:', err);
|
||||
|
@ -126,7 +103,6 @@ app.use((err, req, res, next) => {
|
|||
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
|
||||
});
|
||||
});
|
||||
|
||||
// Handle 404 for unmatched routes
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
|
@ -134,32 +110,28 @@ app.use((req, res) => {
|
|||
message: `Route ${req.method} ${req.path} not found`
|
||||
});
|
||||
});
|
||||
|
||||
// Start the server (HTTP and WebSocket together)
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🎮 RetroPulse server is running on http://localhost:${PORT}`);
|
||||
console.log(`📊 Health check: http://localhost:${PORT}/health`);
|
||||
console.log(`🎯 Main page: http://localhost:${PORT}/`);
|
||||
console.log(`📺 Overlay example: http://localhost:${PORT}/overlay/your-username`);
|
||||
console.log(`🔌 WebSocket endpoint: ws://localhost:${PORT}/ws?user=your-username`);
|
||||
console.log(`RetroPulse server is running on http://localhost:${PORT}`);
|
||||
console.log(`Health check: http://localhost:${PORT}/health`);
|
||||
console.log(`Main page: http://localhost:${PORT}/`);
|
||||
console.log(`Overlay example: http://localhost:${PORT}/overlay/your-username`);
|
||||
console.log(`WebSocket endpoint: ws://localhost:${PORT}/ws?user=your-username`);
|
||||
});
|
||||
|
||||
// Graceful shutdown handling
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('📴 Received SIGTERM, shutting down gracefully');
|
||||
console.log('Received SIGTERM, shutting down gracefully');
|
||||
server.close(() => {
|
||||
console.log('✅ Server closed');
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('📴 Received SIGINT, shutting down gracefully');
|
||||
console.log('Received SIGINT, shutting down gracefully');
|
||||
server.close(() => {
|
||||
console.log('✅ Server closed');
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Export server for potential testing
|
||||
module.exports = server;
|
|
@ -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;
|
||||
padding: 0;
|
||||
|
@ -6,237 +14,148 @@
|
|||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: transparent; /* Transparent for OBS */
|
||||
color: #fff;
|
||||
font-family: 'Courier New', monospace;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.overlay-container {
|
||||
padding: 20px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(30, 60, 114, 0.9);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--accent-blue);
|
||||
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%;
|
||||
max-width: 450px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.waiting-display h2 {
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-size: 14px;
|
||||
background: var(--accent-blue);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.waiting-display p {
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Current Game */
|
||||
.current-game {
|
||||
background: linear-gradient(135deg, rgba(30, 60, 114, 0.9), rgba(42, 82, 152, 0.9));
|
||||
padding: 20px;
|
||||
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);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--accent-blue);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.game-info h2 {
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 5px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.console-name {
|
||||
opacity: 0.8;
|
||||
font-size: 1.1em;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Game Progress Bar */
|
||||
.game-progress {
|
||||
margin-top: 15px;
|
||||
border-top: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 1em;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.9;
|
||||
font-size: 10px;
|
||||
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 {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 20px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1e90ff, #00bfff);
|
||||
border-radius: 20px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
background: var(--accent-gold);
|
||||
}
|
||||
|
||||
/* Achievements Container */
|
||||
.achievements-container {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, rgba(40, 167, 69, 0.9), rgba(32, 134, 55, 0.9));
|
||||
padding: 20px;
|
||||
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);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.achievements-container h3 {
|
||||
font-size: 1.4em;
|
||||
margin-bottom: 15px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
|
||||
padding-bottom: 10px;
|
||||
background: var(--accent-blue);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.achievements-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 400px; /* Adjust as needed */
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-right: 10px;
|
||||
list-style-type: none;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.achievements-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.achievements-list::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.achievements-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.achievements-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.achievements-list::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.achievements-list::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Achievement Item */
|
||||
.achievement-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px;
|
||||
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);
|
||||
}
|
||||
gap: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
.achievement-badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--accent-gold);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
@ -247,96 +166,64 @@ body {
|
|||
|
||||
.achievement-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 3px;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.achievement-description {
|
||||
opacity: 0.8;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.3;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.achievement-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
color: #FFD700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
background: var(--accent-gold);
|
||||
color: var(--bg-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.achievement-date {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Error Display */
|
||||
.error-display {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(220, 53, 69, 0.95);
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
background: #4d1f1f;
|
||||
border: 2px solid #cc0000;
|
||||
text-align: center;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.error-content h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.error-content p {
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.error-content button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.error-content button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
font-size: 12px;
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
font-size: 11px;
|
||||
color: #ff9999;
|
||||
}
|
||||
|
||||
.error-content button {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.hidden {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1e3c72, #2a5298);
|
||||
color: #fff;
|
||||
font-family: 'Courier New', monospace;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
|
||||
font-size: 18px;
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1.2em;
|
||||
opacity: 0.9;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
.user-input {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.user-input h2 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: var(--accent-blue);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
#userForm {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#username {
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1em;
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: #333;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
border-right: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
#username:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.3);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 15px 30px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
background: var(--accent-blue);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1em;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* Section States */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 15px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(220,53,69,0.2);
|
||||
border: 1px solid rgba(220,53,69,0.3);
|
||||
background: #4d1f1f;
|
||||
border-color: #cc0000;
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6b6b;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 15px;
|
||||
color: #ff6666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* User Info Styling */
|
||||
.user-info {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
margin-bottom: 30px;
|
||||
.user-info h2 {
|
||||
font-size: 14px;
|
||||
background: var(--accent-blue);
|
||||
color: var(--bg-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.profile-details h3 {
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.motto {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-primary);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--accent-gold);
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.member-since {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
font-size: 10px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 15px;
|
||||
gap: 10px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
.game-icon, .achievement-badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.game-details h4, .achievement-details h4 {
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.console, .description {
|
||||
opacity: 0.8;
|
||||
font-size: 0.9em;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.achievement-stats {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
gap: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.points, .true-ratio {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
.points {
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
.true-ratio {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Overlay Controls */
|
||||
.overlay-controls {
|
||||
margin-top: 30px;
|
||||
padding: 25px;
|
||||
background: rgba(40,167,69,0.1);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(40,167,69,0.3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--accent-gold);
|
||||
}
|
||||
|
||||
.overlay-controls h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #28a745;
|
||||
font-size: 12px;
|
||||
background: var(--accent-gold);
|
||||
color: var(--bg-primary);
|
||||
text-align: center;
|
||||
}
|
||||
.overlay-controls p {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.overlay-url {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.overlay-url input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
border-right: 1px solid var(--accent-gold);
|
||||
}
|
||||
|
||||
.overlay-button {
|
||||
display: inline-block;
|
||||
padding: 15px 25px;
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
background: var(--accent-gold);
|
||||
color: var(--bg-primary);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
transition: background 0.3s;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.overlay-button:hover {
|
||||
background: #138496;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
}
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-card, .achievement-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#userForm {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#username {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.overlay-url {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
|
@ -1,46 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RetroPulse - RetroAchievements Overlay</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎮 RetroPulse</h1>
|
||||
<h1>RetroPulse</h1>
|
||||
<p>RetroAchievements Overlay for OBS</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Username Input Form -->
|
||||
<section class="user-input">
|
||||
<h2>Enter RetroAchievements Username</h2>
|
||||
<form id="userForm">
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Enter your RA username..."
|
||||
required
|
||||
autocomplete="off"
|
||||
>
|
||||
<input type="text" id="username" placeholder="Enter your RA username..." required
|
||||
autocomplete="off">
|
||||
<button type="submit">Get Info</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Loading State -->
|
||||
<section class="loading hidden" id="loading">
|
||||
<p>🔄 Loading user data...</p>
|
||||
<p>Loading user data...</p>
|
||||
</section>
|
||||
|
||||
<!-- Error Display -->
|
||||
<section class="error hidden" id="error">
|
||||
<p class="error-message"></p>
|
||||
<button onclick="clearError()">Try Again</button>
|
||||
</section>
|
||||
|
||||
<!-- User Info Display -->
|
||||
<section class="user-info hidden" id="userInfo">
|
||||
<h2>User Profile</h2>
|
||||
<div class="profile-card">
|
||||
|
@ -61,8 +50,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Game Info -->
|
||||
<div class="last-game" id="lastGameSection">
|
||||
<h3>Currently Playing</h3>
|
||||
<div class="game-card">
|
||||
|
@ -73,8 +60,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Achievement -->
|
||||
<div class="last-achievement" id="lastAchievementSection">
|
||||
<h3>Latest Achievement</h3>
|
||||
<div class="achievement-card">
|
||||
|
@ -93,8 +78,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay Button -->
|
||||
<div class="overlay-controls">
|
||||
<h3>Overlay for OBS</h3>
|
||||
<p>Use this URL as a Browser Source in OBS Studio:</p>
|
||||
|
@ -103,13 +86,13 @@
|
|||
<button onclick="copyOverlayUrl()">Copy URL</button>
|
||||
</div>
|
||||
<a id="overlayLink" href="#" target="_blank" class="overlay-button">
|
||||
📺 Open Overlay
|
||||
Open Overlay
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,88 +1,68 @@
|
|||
// main.js - Frontend logic for the main page
|
||||
|
||||
let currentUsername = '';
|
||||
|
||||
// DOM elements
|
||||
const userForm = document.getElementById('userForm');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const loadingSection = document.getElementById('loading');
|
||||
const errorSection = document.getElementById('error');
|
||||
const userInfoSection = document.getElementById('userInfo');
|
||||
|
||||
// Initialize event listeners
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
userForm.addEventListener('submit', handleFormSubmit);
|
||||
|
||||
// Auto-focus username input
|
||||
usernameInput.focus();
|
||||
|
||||
// Allow Enter key to submit
|
||||
usernameInput.addEventListener('keypress', function(e) {
|
||||
usernameInput.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleFormSubmit(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
async function handleFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameInput.value.trim();
|
||||
if (!username) {
|
||||
showError('Please enter a username');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
showError('Username can only contain letters, numbers, underscores, and hyphens');
|
||||
return;
|
||||
}
|
||||
|
||||
currentUsername = username;
|
||||
await fetchUserData(username);
|
||||
}
|
||||
|
||||
// Fetch user data from API
|
||||
async function fetchUserData(username) {
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
console.log(`Fetching data for user: ${username}`);
|
||||
|
||||
const response = await fetch(`/api/userinfo?username=${encodeURIComponent(username)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to fetch user data');
|
||||
}
|
||||
|
||||
console.log('User data received:', data);
|
||||
displayUserData(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user data:', error);
|
||||
showError(error.message || 'Failed to load user data. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Display user data in the UI
|
||||
function displayUserData(data) {
|
||||
const { user, lastGame, lastAchievement } = data;
|
||||
|
||||
// Update user profile
|
||||
document.getElementById('userName').textContent = user.displayName;
|
||||
document.getElementById('userMotto').textContent = user.motto || 'No motto set';
|
||||
document.getElementById('totalPoints').textContent = user.totalPoints.toLocaleString();
|
||||
document.getElementById('truePoints').textContent = user.totalTruePoints.toLocaleString();
|
||||
|
||||
// Format member since date
|
||||
if (user.memberSince) {
|
||||
const memberDate = new Date(user.memberSince);
|
||||
document.getElementById('memberSince').textContent = memberDate.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Set avatar
|
||||
const avatarImg = document.getElementById('userAvatar');
|
||||
if (user.avatar) {
|
||||
|
@ -91,13 +71,11 @@ function displayUserData(data) {
|
|||
} else {
|
||||
avatarImg.style.display = 'none';
|
||||
}
|
||||
|
||||
// Display last game
|
||||
const lastGameSection = document.getElementById('lastGameSection');
|
||||
if (lastGame) {
|
||||
document.getElementById('gameTitle').textContent = lastGame.title;
|
||||
document.getElementById('consoleName').textContent = lastGame.consoleName;
|
||||
|
||||
const gameIconImg = document.getElementById('gameIcon');
|
||||
if (lastGame.icon) {
|
||||
gameIconImg.src = lastGame.icon;
|
||||
|
@ -109,7 +87,6 @@ function displayUserData(data) {
|
|||
} else {
|
||||
lastGameSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Display last achievement
|
||||
const lastAchievementSection = document.getElementById('lastAchievementSection');
|
||||
if (lastAchievement) {
|
||||
|
@ -117,7 +94,6 @@ function displayUserData(data) {
|
|||
document.getElementById('achievementDescription').textContent = lastAchievement.description;
|
||||
document.getElementById('achievementPoints').textContent = lastAchievement.points;
|
||||
document.getElementById('achievementTrueRatio').textContent = lastAchievement.trueRatio;
|
||||
|
||||
// Set achievement badge (try badgeName first, then gameIcon as fallback)
|
||||
const achievementBadgeImg = document.getElementById('achievementBadge');
|
||||
// For now, we'll use the game icon since badges aren't in the current API response
|
||||
|
@ -131,60 +107,48 @@ function displayUserData(data) {
|
|||
} else {
|
||||
lastAchievementSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Setup overlay URL and link
|
||||
const overlayUrl = `${window.location.origin}/overlay/${encodeURIComponent(currentUsername)}`;
|
||||
document.getElementById('overlayUrl').value = overlayUrl;
|
||||
document.getElementById('overlayLink').href = overlayUrl;
|
||||
|
||||
showUserInfo();
|
||||
}
|
||||
|
||||
// UI state management functions
|
||||
function showLoading() {
|
||||
hideAllSections();
|
||||
loadingSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
hideAllSections();
|
||||
document.querySelector('.error-message').textContent = message;
|
||||
errorSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showUserInfo() {
|
||||
hideAllSections();
|
||||
userInfoSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideAllSections() {
|
||||
loadingSection.classList.add('hidden');
|
||||
errorSection.classList.add('hidden');
|
||||
userInfoSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
showUserInfo();
|
||||
}
|
||||
|
||||
// Copy overlay URL to clipboard
|
||||
async function copyOverlayUrl() {
|
||||
const overlayUrlInput = document.getElementById('overlayUrl');
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(overlayUrlInput.value);
|
||||
|
||||
// Show feedback
|
||||
const button = event.target;
|
||||
const originalText = button.textContent;
|
||||
button.textContent = 'Copied!';
|
||||
button.style.background = '#28a745';
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.background = '';
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
overlayUrlInput.select();
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
// overlay.js - WebSocket client for real-time achievement updates
|
||||
class RetroPulseOverlay {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
|
@ -6,66 +5,50 @@ class RetroPulseOverlay {
|
|||
this.reconnectInterval = null;
|
||||
this.reconnectDelay = 3000; // 3 seconds
|
||||
this.lastUpdateTime = null;
|
||||
|
||||
// DOM elements
|
||||
this.connectionStatus = document.getElementById('connection-status');
|
||||
this.errorDisplay = document.getElementById('error-display');
|
||||
this.errorMessage = document.getElementById('error-message');
|
||||
this.waitingForActivity = document.getElementById('waiting-for-activity');
|
||||
|
||||
// Game elements
|
||||
this.currentGame = document.getElementById('current-game');
|
||||
this.gameIcon = document.getElementById('game-icon');
|
||||
this.gameTitle = document.getElementById('game-title');
|
||||
this.consoleName = document.getElementById('console-name');
|
||||
|
||||
// Progress bar elements
|
||||
this.gameProgressSection = document.getElementById('game-progress-section');
|
||||
this.progressText = document.getElementById('progress-text');
|
||||
this.progressPercentage = document.getElementById('progress-percentage');
|
||||
this.progressBar = document.getElementById('progress-bar');
|
||||
|
||||
// Achievements elements
|
||||
this.achievementsContainer = document.getElementById('achievements-container');
|
||||
this.achievementsList = document.getElementById('achievements-list');
|
||||
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Extract username from URL path
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
this.username = decodeURIComponent(pathParts[pathParts.length - 1]);
|
||||
|
||||
if (!this.username || this.username === 'overlay') {
|
||||
this.showError('No username specified in URL');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🎮 Initializing RetroPulse overlay for user: ${this.username}`);
|
||||
console.log(`Initializing RetroPulse overlay for user: ${this.username}`);
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
try {
|
||||
// Determine WebSocket URL (ws:// for http, wss:// for https)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws?user=${encodeURIComponent(this.username)}`;
|
||||
|
||||
console.log(`🔌 Connecting to WebSocket: ${wsUrl}`);
|
||||
this.updateConnectionStatus('connecting');
|
||||
console.log(`Connecting to WebSocket: ${wsUrl}`);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('✅ WebSocket connected');
|
||||
this.updateConnectionStatus('connected');
|
||||
console.log('WebSocket connected');
|
||||
if (this.reconnectInterval) {
|
||||
clearInterval(this.reconnectInterval);
|
||||
this.reconnectInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
@ -74,38 +57,30 @@ class RetroPulseOverlay {
|
|||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('❌ WebSocket disconnected:', event.code, event.reason);
|
||||
this.updateConnectionStatus('disconnected');
|
||||
console.log('WebSocket disconnected:', event.code, event.reason);
|
||||
if (event.code !== 1000) { // Don't reconnect on clean close
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.updateConnectionStatus('disconnected');
|
||||
// The onclose event will fire next, which will handle reconnecting.
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket connection:', error);
|
||||
this.showError(`Connection failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
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(() => {
|
||||
console.log('⌛ Reconnecting...');
|
||||
console.log('Reconnecting...');
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
console.log('📨 Received message:', message);
|
||||
console.log('Received message:', message);
|
||||
if (message.error) {
|
||||
this.showError(message.error);
|
||||
return;
|
||||
|
@ -114,21 +89,16 @@ class RetroPulseOverlay {
|
|||
this.updateOverlay(message.data);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverlay(data) {
|
||||
if (!data) return;
|
||||
console.log('🔄 Updating overlay with data:', data);
|
||||
|
||||
console.log('Updating overlay with data:', data);
|
||||
this.lastUpdateTime = new Date();
|
||||
this.errorDisplay.classList.add('hidden');
|
||||
|
||||
const hasGameProgress = data.gameProgress !== null;
|
||||
const hasAchievements = data.recentAchievements && data.recentAchievements.length > 0;
|
||||
|
||||
if (hasGameProgress || hasAchievements) {
|
||||
// We have data, so hide the waiting message
|
||||
this.waitingForActivity.classList.add('hidden');
|
||||
|
||||
// Update Game Info and Progress Bar
|
||||
if (hasGameProgress) {
|
||||
this.currentGame.classList.remove('hidden');
|
||||
|
@ -136,7 +106,6 @@ class RetroPulseOverlay {
|
|||
} else {
|
||||
this.currentGame.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update achievements list
|
||||
if (hasAchievements) {
|
||||
this.achievementsContainer.classList.remove('hidden');
|
||||
|
@ -151,20 +120,16 @@ class RetroPulseOverlay {
|
|||
this.achievementsContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
updateGameInfo(gameData) {
|
||||
this.currentGame.classList.remove('hidden');
|
||||
|
||||
this.gameTitle.textContent = gameData.title;
|
||||
this.consoleName.textContent = gameData.consoleName || '';
|
||||
|
||||
if(gameData.icon) {
|
||||
if (gameData.icon) {
|
||||
this.gameIcon.src = gameData.icon;
|
||||
this.gameIcon.style.display = 'block';
|
||||
} else {
|
||||
this.gameIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
if (gameData.hasOwnProperty('completion') && gameData.totalAchievements > 0) {
|
||||
this.progressText.textContent = `Completion: ${gameData.numAwarded} / ${gameData.totalAchievements}`;
|
||||
|
@ -175,58 +140,44 @@ class RetroPulseOverlay {
|
|||
this.gameProgressSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
updateAchievements(achievements) {
|
||||
// To avoid flickering, only update if content has changed.
|
||||
// A simple check on the first achievement ID can work.
|
||||
const firstExisting = this.achievementsList.querySelector('.achievement-item');
|
||||
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;
|
||||
}
|
||||
|
||||
console.log('✨ Updating achievements list.');
|
||||
console.log('Updating achievements list.');
|
||||
this.achievementsList.innerHTML = ''; // Clear existing
|
||||
achievements.forEach(ach => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'achievement-item';
|
||||
item.dataset.id = ach.id; // For update checking
|
||||
|
||||
const earnedDate = new Date(ach.dateEarned).toLocaleString();
|
||||
|
||||
item.innerHTML = `
|
||||
<img src="${ach.badgeName}" alt="Badge" class="achievement-badge">
|
||||
<div class="achievement-details">
|
||||
<div class="achievement-title">${ach.title}</div>
|
||||
<div class="achievement-description">${ach.description}</div>
|
||||
<div class="achievement-meta">
|
||||
<span class="achievement-points">${ach.points} Pts</span>
|
||||
<span class="achievement-date">${earnedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="achievement-badge">
|
||||
<img src="${ach.badgeName || ach.gameIcon || ''}" alt="Achievement Badge" style="width: 100%; height: 100%; object-fit: cover;">
|
||||
</div>
|
||||
<div class="achievement-details">
|
||||
<div class="achievement-title">${ach.title}</div>
|
||||
<div class="achievement-description">${ach.description}</div>
|
||||
<div class="achievement-meta">
|
||||
<span class="achievement-points">${ach.points}pts</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.achievementsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
updateConnectionStatus(status) {
|
||||
this.connectionStatus.className = 'connection-status'; // Reset classes
|
||||
this.connectionStatus.classList.add(status);
|
||||
this.connectionStatus.textContent = status;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
console.error('Overlay Error:', message);
|
||||
this.errorMessage.textContent = message;
|
||||
this.errorDisplay.classList.remove('hidden');
|
||||
// Hide other elements
|
||||
this.waitingForActivity.classList.add('hidden');
|
||||
this.currentGame.classList.add('hidden');
|
||||
this.achievementsContainer.classList.add('hidden');
|
||||
this.waitingForActivity.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the overlay logic
|
||||
// Initialize the overlay when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new RetroPulseOverlay();
|
||||
});
|
||||
|
|
|
@ -1,23 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RetroPulse Overlay</title>
|
||||
<link rel="stylesheet" href="/css/overlay.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<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">
|
||||
<h2>Waiting for Game Activity</h2>
|
||||
<p>Start playing a game with RetroAchievements enabled. New achievements will appear here automatically.</p>
|
||||
</section>
|
||||
|
||||
<!-- Current Game -->
|
||||
<section id="current-game" class="current-game hidden">
|
||||
<div class="game-header">
|
||||
<img id="game-icon" src="" alt="Game Icon" class="game-icon">
|
||||
|
@ -26,7 +22,6 @@
|
|||
<p id="console-name" class="console-name"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Game Progress Bar -->
|
||||
<div id="game-progress-section" class="game-progress hidden">
|
||||
<div class="progress-stats">
|
||||
<span id="progress-text">Completion: 0/0</span>
|
||||
|
@ -37,16 +32,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Achievements -->
|
||||
<section id="achievements-container" class="achievements-container hidden">
|
||||
<h3>Recent Achievements</h3>
|
||||
<ul id="achievements-list" class="achievements-list">
|
||||
<!-- Achievement items will be injected here -->
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div id="error-display" class="error-display hidden">
|
||||
<div class="error-content">
|
||||
<h3>Error</h3>
|
||||
|
@ -55,7 +45,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/overlay.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in a new issue