main
This commit is contained in:
commit
44b935e044
3
.env.example
Normal file
3
.env.example
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
PORT=3000
|
||||||
|
RA_USER=your_retroachievements_username
|
||||||
|
RA_KEY=your_retroachievements_api_key
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.directory
|
218
backend/retroClient.js
Normal file
218
backend/retroClient.js
Normal file
|
@ -0,0 +1,218 @@
|
||||||
|
// backend/retroClient.js
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildAuthorization,
|
||||||
|
getUserProfile,
|
||||||
|
getUserRecentAchievements,
|
||||||
|
getGame
|
||||||
|
} = 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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('/')) {
|
||||||
|
// Ensure .png for badges
|
||||||
|
if (pathStr.startsWith('/Badge/')) {
|
||||||
|
const name = pathStr.split('/').pop().replace(/\.(png|jpe?g|gif|webp)$/i, '');
|
||||||
|
return `https://media.retroachievements.org/Badge/${name}.png`;
|
||||||
|
}
|
||||||
|
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}`);
|
||||||
|
const profile = await getUserProfile(this.authorization, { username });
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
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}`);
|
||||||
|
const achievements = await getUserRecentAchievements(this.authorization, {
|
||||||
|
username,
|
||||||
|
count
|
||||||
|
});
|
||||||
|
return achievements;
|
||||||
|
} catch (error) {
|
||||||
|
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}`);
|
||||||
|
const game = await getGame(this.authorization, { gameId });
|
||||||
|
return game;
|
||||||
|
} catch (error) {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recentAchievements = await this.getUserRecentAchievements(username, 1);
|
||||||
|
if (recentAchievements && recentAchievements.length > 0) {
|
||||||
|
lastAchievement = recentAchievements;
|
||||||
|
}
|
||||||
|
} catch (achievementError) {
|
||||||
|
console.warn(`⚠️ Could not fetch recent achievements: ${achievementError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
username: profile.user,
|
||||||
|
displayName: profile.user,
|
||||||
|
avatar: this.buildImageUrl(
|
||||||
|
profile.userPic,
|
||||||
|
'https://media.retroachievements.org/UserPic'
|
||||||
|
),
|
||||||
|
motto: profile.motto || '',
|
||||||
|
totalPoints: profile.totalPoints || 0,
|
||||||
|
totalTruePoints: profile.totalTruePoints || 0,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
: 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
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error in getUserData for ${username}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOverlayData(username, count = 5) {
|
||||||
|
try {
|
||||||
|
const recentAchievements = await this.getUserRecentAchievements(username, count);
|
||||||
|
|
||||||
|
const formattedAchievements = recentAchievements.map((achievement) => {
|
||||||
|
// Debug log to see what we're getting from the API
|
||||||
|
console.log(
|
||||||
|
`🔍 Processing badge for achievement "${achievement.title}": badgeName="${achievement.badgeName}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: achievement.achievementId,
|
||||||
|
title: achievement.title,
|
||||||
|
description: achievement.description,
|
||||||
|
points: achievement.points,
|
||||||
|
trueRatio: achievement.trueRatio || achievement.points,
|
||||||
|
gameTitle: achievement.gameTitle,
|
||||||
|
gameIcon: this.buildImageUrl(
|
||||||
|
achievement.gameIcon,
|
||||||
|
'https://media.retroachievements.org/Images'
|
||||||
|
),
|
||||||
|
badgeName: this.buildImageUrl(
|
||||||
|
achievement.badgeName,
|
||||||
|
'https://media.retroachievements.org/Badge'
|
||||||
|
),
|
||||||
|
dateEarned: achievement.dateEarned
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
recentAchievements: formattedAchievements,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error in getOverlayData for ${username}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new RetroClient();
|
122
backend/routes/api.js
Normal file
122
backend/routes/api.js
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
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({
|
||||||
|
error: 'Invalid username format',
|
||||||
|
message: 'Username can only contain letters, numbers, underscores, and hyphens'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
res.json(userData);
|
||||||
|
|
||||||
|
} catch (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({
|
||||||
|
error: 'User not found',
|
||||||
|
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({
|
||||||
|
error: 'Rate limited',
|
||||||
|
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({
|
||||||
|
error: 'Invalid username format',
|
||||||
|
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`);
|
||||||
|
|
||||||
|
// Fetch real achievement data from RetroAchievements
|
||||||
|
const overlayData = await retroClient.getOverlayData(username, achievementCount);
|
||||||
|
|
||||||
|
console.log(`✅ Successfully fetched ${overlayData.recentAchievements.length} achievements for user: ${username}`);
|
||||||
|
res.json(overlayData);
|
||||||
|
|
||||||
|
} catch (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({
|
||||||
|
error: 'User not found',
|
||||||
|
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 {
|
||||||
|
// Test basic API connection (this is safe as it doesn't need a specific user)
|
||||||
|
res.json({
|
||||||
|
status: 'OK',
|
||||||
|
message: 'RetroPulse API is operational',
|
||||||
|
retroAchievements: 'Connected',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(503).json({
|
||||||
|
status: 'ERROR',
|
||||||
|
message: 'Service unavailable',
|
||||||
|
retroAchievements: 'Connection failed',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
0
backend/routes/websocket.js
Normal file
0
backend/routes/websocket.js
Normal file
165
backend/server.js
Normal file
165
backend/server.js
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
// 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({
|
||||||
|
status: 'OK',
|
||||||
|
message: 'RetroPulse server is running',
|
||||||
|
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)'
|
||||||
|
}));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'initial',
|
||||||
|
data: initialData
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
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) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
userConnections.delete(username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedData = await retroClient.getOverlayData(username, 5);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'update',
|
||||||
|
data: updatedData
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 404 for unmatched routes
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Not found',
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown handling
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('📴 Received SIGTERM, shutting down gracefully');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('✅ Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('📴 Received SIGINT, shutting down gracefully');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('✅ Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export server for potential testing
|
||||||
|
module.exports = server;
|
870
package-lock.json
generated
Normal file
870
package-lock.json
generated
Normal file
|
@ -0,0 +1,870 @@
|
||||||
|
{
|
||||||
|
"name": "retropulse",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "retropulse",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@retroachievements/api": "^2.6.0",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@retroachievements/api": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@retroachievements/api/-/api-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-ra6tSHYRJ1Mdm25GtlwQUAQLfrad32hOfBwYRHQo+Fv+vjeki3jEtXX/KNqFGZK+5DKH6S1rSgVoGBdXVbNzkA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"negotiator": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "^3.1.2",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"type-is": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
|
||||||
|
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "^2.0.0",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"content-disposition": "^1.0.0",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cookie": "^0.7.1",
|
||||||
|
"cookie-signature": "^1.2.1",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"finalhandler": "^2.1.0",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"merge-descriptors": "^2.0.0",
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"once": "^1.4.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"proxy-addr": "^2.0.7",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"router": "^2.2.0",
|
||||||
|
"send": "^1.1.0",
|
||||||
|
"serve-static": "^2.2.0",
|
||||||
|
"statuses": "^2.0.1",
|
||||||
|
"type-is": "^2.0.1",
|
||||||
|
"vary": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"statuses": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"inherits": "2.0.4",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"toidentifier": "1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors/node_modules/statuses": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-promise": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"iconv-lite": "0.6.3",
|
||||||
|
"unpipe": "1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/router": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
|
"is-promise": "^4.0.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"path-to-regexp": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.5",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"statuses": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"send": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
package.json
Normal file
19
package.json
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "retropulse",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A lightweight real-time overlay webapp that displays your RetroAchievements progress live on stream. Shows your current game and latest achievements with instant WebSocket updates, perfect for OBS browser sources.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node backend/server.js",
|
||||||
|
"dev": "node backend/server.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "aiquiral",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@retroachievements/api": "^2.6.0",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
}
|
||||||
|
}
|
288
public/css/overlay.css
Normal file
288
public/css/overlay.css
Normal file
|
@ -0,0 +1,288 @@
|
||||||
|
/* Overlay CSS - Designed for OBS Browser Source */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: transparent; /* Transparent for OBS */
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-container {
|
||||||
|
padding: 20px;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-info h2 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-name {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements-container h3 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-details {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-description {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
283
public/css/style.css
Normal file
283
public/css/style.css
Normal file
|
@ -0,0 +1,283 @@
|
||||||
|
/* Basic styling for RetroPulse */
|
||||||
|
* {
|
||||||
|
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;
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 3em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-input h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#username:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 15px 30px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(220,53,69,0.2);
|
||||||
|
border: 1px solid rgba(220,53,69,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details h3 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motto {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-since {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-icon, .achievement-badge {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-details h4, .achievement-details h4 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console, .description {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points, .true-ratio {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-controls h3 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-url {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-url input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
color: #333;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 15px 25px;
|
||||||
|
background: #17a2b8;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-button:hover {
|
||||||
|
background: #138496;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
115
public/index.html
Normal file
115
public/index.html
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<!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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<button type="submit">Get Info</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<section class="loading hidden" id="loading">
|
||||||
|
<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">
|
||||||
|
<div class="profile-header">
|
||||||
|
<img id="userAvatar" src="" alt="User Avatar" class="avatar">
|
||||||
|
<div class="profile-details">
|
||||||
|
<h3 id="userName"></h3>
|
||||||
|
<p id="userMotto" class="motto"></p>
|
||||||
|
<div class="stats">
|
||||||
|
<span class="stat">
|
||||||
|
<strong id="totalPoints"></strong> Points
|
||||||
|
</span>
|
||||||
|
<span class="stat">
|
||||||
|
<strong id="truePoints"></strong> True Points
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="member-since">Member since: <span id="memberSince"></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Game Info -->
|
||||||
|
<div class="last-game" id="lastGameSection">
|
||||||
|
<h3>Currently Playing</h3>
|
||||||
|
<div class="game-card">
|
||||||
|
<img id="gameIcon" src="" alt="Game Icon" class="game-icon">
|
||||||
|
<div class="game-details">
|
||||||
|
<h4 id="gameTitle"></h4>
|
||||||
|
<p id="consoleName" class="console"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Achievement -->
|
||||||
|
<div class="last-achievement" id="lastAchievementSection">
|
||||||
|
<h3>Latest Achievement</h3>
|
||||||
|
<div class="achievement-card">
|
||||||
|
<img id="achievementBadge" src="" alt="Achievement Badge" class="achievement-badge">
|
||||||
|
<div class="achievement-details">
|
||||||
|
<h4 id="achievementTitle"></h4>
|
||||||
|
<p id="achievementDescription" class="description"></p>
|
||||||
|
<div class="achievement-stats">
|
||||||
|
<span class="points">
|
||||||
|
<strong id="achievementPoints"></strong> pts
|
||||||
|
</span>
|
||||||
|
<span class="true-ratio">
|
||||||
|
(True: <strong id="achievementTrueRatio"></strong> pts)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<div class="overlay-url">
|
||||||
|
<input type="text" id="overlayUrl" readonly>
|
||||||
|
<button onclick="copyOverlayUrl()">Copy URL</button>
|
||||||
|
</div>
|
||||||
|
<a id="overlayLink" href="#" target="_blank" class="overlay-button">
|
||||||
|
📺 Open Overlay
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
194
public/js/main.js
Normal file
194
public/js/main.js
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
// 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() {
|
||||||
|
userForm.addEventListener('submit', handleFormSubmit);
|
||||||
|
|
||||||
|
// Auto-focus username input
|
||||||
|
usernameInput.focus();
|
||||||
|
|
||||||
|
// Allow Enter key to submit
|
||||||
|
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) {
|
||||||
|
avatarImg.src = user.avatar;
|
||||||
|
avatarImg.style.display = 'block';
|
||||||
|
} 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;
|
||||||
|
gameIconImg.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
gameIconImg.style.display = 'none';
|
||||||
|
}
|
||||||
|
lastGameSection.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
lastGameSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display last achievement
|
||||||
|
const lastAchievementSection = document.getElementById('lastAchievementSection');
|
||||||
|
if (lastAchievement) {
|
||||||
|
document.getElementById('achievementTitle').textContent = lastAchievement.title;
|
||||||
|
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
|
||||||
|
if (lastAchievement.gameIcon) {
|
||||||
|
achievementBadgeImg.src = lastAchievement.gameIcon;
|
||||||
|
achievementBadgeImg.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
achievementBadgeImg.style.display = 'none';
|
||||||
|
}
|
||||||
|
lastAchievementSection.style.display = 'block';
|
||||||
|
} 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();
|
||||||
|
document.execCommand('copy');
|
||||||
|
alert('Overlay URL copied to clipboard!');
|
||||||
|
}
|
||||||
|
}
|
242
public/js/overlay.js
Normal file
242
public/js/overlay.js
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
// overlay.js - WebSocket client for real-time achievement updates
|
||||||
|
|
||||||
|
class RetroPulseOverlay {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null;
|
||||||
|
this.username = null;
|
||||||
|
this.reconnectInterval = null;
|
||||||
|
this.reconnectDelay = 3000; // 3 seconds
|
||||||
|
this.lastUpdateTime = null;
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
this.connectionStatus = document.getElementById('connection-status');
|
||||||
|
this.currentGame = document.getElementById('current-game');
|
||||||
|
this.gameIcon = document.getElementById('game-icon');
|
||||||
|
this.gameTitle = document.getElementById('game-title');
|
||||||
|
this.consoleName = document.getElementById('console-name');
|
||||||
|
this.achievementsList = document.getElementById('achievements-list');
|
||||||
|
this.errorDisplay = document.getElementById('error-display');
|
||||||
|
this.errorMessage = document.getElementById('error-message');
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Extract username from URL path
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
this.username = 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}`);
|
||||||
|
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.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('✅ WebSocket connected');
|
||||||
|
this.updateConnectionStatus('connected');
|
||||||
|
|
||||||
|
// Clear reconnect interval if it exists
|
||||||
|
if (this.reconnectInterval) {
|
||||||
|
clearInterval(this.reconnectInterval);
|
||||||
|
this.reconnectInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
this.handleMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
console.log('❌ WebSocket disconnected:', event.code, event.reason);
|
||||||
|
this.updateConnectionStatus('disconnected');
|
||||||
|
|
||||||
|
// Don't reconnect if it was a clean close
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
this.updateConnectionStatus('disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating WebSocket connection:', error);
|
||||||
|
this.showError(`Connection failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(message) {
|
||||||
|
console.log('📨 Received message:', message);
|
||||||
|
|
||||||
|
if (message.error) {
|
||||||
|
this.showError(message.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'initial' || message.type === 'update') {
|
||||||
|
this.updateOverlay(message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlay(data) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
console.log('🔄 Updating overlay with data:', data);
|
||||||
|
|
||||||
|
// Update last update time
|
||||||
|
this.lastUpdateTime = new Date();
|
||||||
|
|
||||||
|
// Hide error display
|
||||||
|
this.errorDisplay.classList.add('hidden');
|
||||||
|
|
||||||
|
// Update achievements
|
||||||
|
if (data.recentAchievements && data.recentAchievements.length > 0) {
|
||||||
|
this.updateAchievements(data.recentAchievements);
|
||||||
|
|
||||||
|
// Update game info from the latest achievement
|
||||||
|
const latestAchievement = data.recentAchievements[0];
|
||||||
|
if (latestAchievement.gameTitle) {
|
||||||
|
this.updateGameInfo({
|
||||||
|
title: latestAchievement.gameTitle,
|
||||||
|
icon: latestAchievement.gameIcon
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No achievements yet
|
||||||
|
this.achievementsList.innerHTML = '<div class="no-achievements">No recent achievements</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGameInfo(gameData) {
|
||||||
|
if (!gameData) return;
|
||||||
|
|
||||||
|
this.gameTitle.textContent = gameData.title || 'Unknown Game';
|
||||||
|
|
||||||
|
if (gameData.icon) {
|
||||||
|
this.gameIcon.src = gameData.icon;
|
||||||
|
this.gameIcon.onerror = () => {
|
||||||
|
this.gameIcon.style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentGame.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAchievements(achievements) {
|
||||||
|
// Clear existing achievements
|
||||||
|
this.achievementsList.innerHTML = '';
|
||||||
|
|
||||||
|
achievements.forEach((achievement, index) => {
|
||||||
|
const achievementElement = this.createAchievementElement(achievement);
|
||||||
|
|
||||||
|
// Add a small delay for staggered animation
|
||||||
|
setTimeout(() => {
|
||||||
|
this.achievementsList.appendChild(achievementElement);
|
||||||
|
}, index * 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createAchievementElement(achievement) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'achievement-item';
|
||||||
|
|
||||||
|
// Format the date
|
||||||
|
const dateEarned = achievement.dateEarned ?
|
||||||
|
new Date(achievement.dateEarned).toLocaleDateString() :
|
||||||
|
'Recently';
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<img src="${achievement.badgeName || achievement.gameIcon || ''}"
|
||||||
|
alt="Achievement Badge"
|
||||||
|
class="achievement-badge"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<div class="achievement-details">
|
||||||
|
<div class="achievement-title">${this.escapeHtml(achievement.title || 'Unknown Achievement')}</div>
|
||||||
|
<div class="achievement-description">${this.escapeHtml(achievement.description || '')}</div>
|
||||||
|
<div class="achievement-meta">
|
||||||
|
<span class="achievement-points">${achievement.points || 0} pts</span>
|
||||||
|
<span class="achievement-date">${dateEarned}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConnectionStatus(status) {
|
||||||
|
this.connectionStatus.className = `connection-status ${status}`;
|
||||||
|
|
||||||
|
const statusTexts = {
|
||||||
|
connecting: 'Connecting...',
|
||||||
|
connected: 'Live',
|
||||||
|
disconnected: 'Disconnected'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.connectionStatus.querySelector('.status-text').textContent = statusTexts[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectInterval) return;
|
||||||
|
|
||||||
|
console.log(`⏰ Scheduling reconnect in ${this.reconnectDelay}ms`);
|
||||||
|
|
||||||
|
this.reconnectInterval = setTimeout(() => {
|
||||||
|
console.log('🔄 Attempting to reconnect...');
|
||||||
|
this.connect();
|
||||||
|
}, this.reconnectDelay);
|
||||||
|
|
||||||
|
// Increase delay for next reconnect (max 30 seconds)
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
console.error('❌ Overlay error:', message);
|
||||||
|
this.errorMessage.textContent = message;
|
||||||
|
this.errorDisplay.classList.remove('hidden');
|
||||||
|
this.updateConnectionStatus('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to escape HTML
|
||||||
|
escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize overlay when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new RetroPulseOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page visibility for OBS
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
console.log('🔄 Page became visible, overlay active');
|
||||||
|
}
|
||||||
|
});
|
47
public/overlay.html
Normal file
47
public/overlay.html
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<!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 class="overlay-container">
|
||||||
|
<!-- Connection Status -->
|
||||||
|
<div id="connection-status" class="connection-status connecting">
|
||||||
|
<span class="status-text">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Game Info -->
|
||||||
|
<div id="current-game" class="current-game hidden">
|
||||||
|
<div class="game-header">
|
||||||
|
<img id="game-icon" src="" alt="Game Icon" class="game-icon">
|
||||||
|
<div class="game-info">
|
||||||
|
<h2 id="game-title">Loading...</h2>
|
||||||
|
<p id="console-name" class="console-name"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Achievements -->
|
||||||
|
<div id="achievements-container" class="achievements-container">
|
||||||
|
<h3>Recent Achievements</h3>
|
||||||
|
<div id="achievements-list" class="achievements-list">
|
||||||
|
<!-- Achievements will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
<div id="error-display" class="error-display hidden">
|
||||||
|
<div class="error-content">
|
||||||
|
<h3>Connection Error</h3>
|
||||||
|
<p id="error-message"></p>
|
||||||
|
<button onclick="location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue