Initial commit: Ham Radio Award Portal
Features implemented: - User authentication (register/login) with JWT - SQLite database with Drizzle ORM - SvelteKit frontend with authentication flow - ElysiaJS backend with CORS enabled - Award definition JSON schemas (DXCC, WAS, VUCC, SAT) - Responsive dashboard with user profile Tech stack: - Backend: ElysiaJS, Drizzle ORM, SQLite, JWT - Frontend: SvelteKit, Svelte stores - Runtime: Bun - Language: JavaScript Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
23
src/backend/config/database.js
Normal file
23
src/backend/config/database.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import Database from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import * as schema from '../db/schema/index.js';
|
||||
|
||||
// Create SQLite database connection
|
||||
const sqlite = new Database('./award.db');
|
||||
|
||||
// Enable foreign keys
|
||||
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create Drizzle instance
|
||||
export const db = drizzle({
|
||||
client: sqlite,
|
||||
schema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function closeDatabase() {
|
||||
sqlite.close();
|
||||
}
|
||||
9
src/backend/config/jwt.js
Normal file
9
src/backend/config/jwt.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
|
||||
/**
|
||||
* JWT secret key - should be in environment variables in production
|
||||
* For development, we use a default value
|
||||
*/
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
export { JWT_SECRET };
|
||||
146
src/backend/db/schema/index.js
Normal file
146
src/backend/db/schema/index.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
/**
|
||||
* @typedef {Object} User
|
||||
* @property {number} id
|
||||
* @property {string} email
|
||||
* @property {string} passwordHash
|
||||
* @property {string} callsign
|
||||
* @property {string|null} lotwUsername
|
||||
* @property {string|null} lotwPassword
|
||||
* @property {Date} createdAt
|
||||
* @property {Date} updatedAt
|
||||
*/
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
callsign: text('callsign').notNull(),
|
||||
lotwUsername: text('lotw_username'),
|
||||
lotwPassword: text('lotw_password'), // Encrypted
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} QSO
|
||||
* @property {number} id
|
||||
* @property {number} userId
|
||||
* @property {string} callsign
|
||||
* @property {string} qsoDate
|
||||
* @property {string} timeOn
|
||||
* @property {string|null} band
|
||||
* @property {string|null} mode
|
||||
* @property {number|null} freq
|
||||
* @property {number|null} freqRx
|
||||
* @property {string|null} entity
|
||||
* @property {number|null} entityId
|
||||
* @property {string|null} grid
|
||||
* @property {string|null} gridSource
|
||||
* @property {string|null} continent
|
||||
* @property {number|null} cqZone
|
||||
* @property {number|null} ituZone
|
||||
* @property {string|null} state
|
||||
* @property {string|null} county
|
||||
* @property {string|null} satName
|
||||
* @property {string|null} satMode
|
||||
* @property {string|null} lotwQslRdate
|
||||
* @property {string|null} lotwQslRstatus
|
||||
* @property {Date|null} lotwSyncedAt
|
||||
* @property {Date} createdAt
|
||||
*/
|
||||
|
||||
export const qsos = sqliteTable('qsos', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
|
||||
// QSO fields
|
||||
callsign: text('callsign').notNull(),
|
||||
qsoDate: text('qso_date').notNull(), // ADIF format: YYYYMMDD
|
||||
timeOn: text('time_on').notNull(), // HHMMSS
|
||||
band: text('band'), // 160m, 80m, 40m, etc.
|
||||
mode: text('mode'), // CW, SSB, FT8, etc.
|
||||
freq: integer('freq'), // Frequency in Hz
|
||||
freqRx: integer('freq_rx'), // RX frequency (satellite)
|
||||
|
||||
// Entity/location fields
|
||||
entity: text('entity'), // DXCC entity name
|
||||
entityId: integer('entity_id'), // DXCC entity number
|
||||
grid: text('grid'), // Maidenhead grid square
|
||||
gridSource: text('grid_source'), // LOTW, USER, CALC
|
||||
continent: text('continent'), // NA, SA, EU, AF, AS, OC, AN
|
||||
cqZone: integer('cq_zone'),
|
||||
ituZone: integer('itu_zone'),
|
||||
state: text('state'), // US state, CA province, etc.
|
||||
county: text('county'),
|
||||
|
||||
// Satellite fields
|
||||
satName: text('sat_name'),
|
||||
satMode: text('sat_mode'),
|
||||
|
||||
// LoTW confirmation
|
||||
lotwQslRdate: text('lotw_qsl_rdate'), // Confirmation date
|
||||
lotwQslRstatus: text('lotw_qsl_rstatus'), // 'Y', 'N', '?'
|
||||
|
||||
// Cache metadata
|
||||
lotwSyncedAt: integer('lotw_synced_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} Award
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {string|null} description
|
||||
* @property {string} definition
|
||||
* @property {boolean} isActive
|
||||
* @property {Date} createdAt
|
||||
*/
|
||||
|
||||
export const awards = sqliteTable('awards', {
|
||||
id: text('id').primaryKey(), // 'dxcc', 'was', 'vucc'
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
definition: text('definition').notNull(), // JSON rule definition
|
||||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} AwardProgress
|
||||
* @property {number} id
|
||||
* @property {number} userId
|
||||
* @property {string} awardId
|
||||
* @property {number} workedCount
|
||||
* @property {number} confirmedCount
|
||||
* @property {number} totalRequired
|
||||
* @property {string|null} workedEntities
|
||||
* @property {string|null} confirmedEntities
|
||||
* @property {Date|null} lastCalculatedAt
|
||||
* @property {Date|null} lastQsoSyncAt
|
||||
* @property {Date} updatedAt
|
||||
*/
|
||||
|
||||
export const awardProgress = sqliteTable('award_progress', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
awardId: text('award_id').notNull().references(() => awards.id),
|
||||
|
||||
// Calculated progress
|
||||
workedCount: integer('worked_count').notNull().default(0),
|
||||
confirmedCount: integer('confirmed_count').notNull().default(0),
|
||||
totalRequired: integer('total_required').notNull(),
|
||||
|
||||
// Detailed breakdown (JSON)
|
||||
workedEntities: text('worked_entities'), // JSON array
|
||||
confirmedEntities: text('confirmed_entities'), // JSON array
|
||||
|
||||
// Cache control
|
||||
lastCalculatedAt: integer('last_calculated_at', { mode: 'timestamp' }),
|
||||
lastQsoSyncAt: integer('last_qso_sync_at', { mode: 'timestamp' }),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Export all schemas
|
||||
export const schema = { users, qsos, awards, awardProgress };
|
||||
216
src/backend/index.js
Normal file
216
src/backend/index.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { cors } from '@elysiajs/cors';
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
import { JWT_SECRET } from './config/jwt.js';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
getUserById,
|
||||
updateLoTWCredentials,
|
||||
} from './services/auth.service.js';
|
||||
|
||||
/**
|
||||
* Main backend application
|
||||
* Serves API routes
|
||||
*/
|
||||
const app = new Elysia()
|
||||
// Enable CORS for frontend communication
|
||||
.use(cors({
|
||||
origin: true, // Allow all origins in development
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
}))
|
||||
|
||||
// JWT plugin
|
||||
.use(jwt({
|
||||
name: 'jwt',
|
||||
secret: JWT_SECRET,
|
||||
}))
|
||||
|
||||
// Authentication: derive user from JWT token
|
||||
.derive(async ({ jwt, headers }) => {
|
||||
const authHeader = headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const payload = await jwt.verify(token);
|
||||
if (!payload) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: payload.userId,
|
||||
email: payload.email,
|
||||
callsign: payload.callsign,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { user: null };
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Register a new user
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/register',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Create user
|
||||
const user = await registerUser(body);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({
|
||||
format: 'email',
|
||||
error: 'Invalid email address',
|
||||
}),
|
||||
password: t.String({
|
||||
minLength: 8,
|
||||
error: 'Password must be at least 8 characters',
|
||||
}),
|
||||
callsign: t.String({
|
||||
minLength: 3,
|
||||
maxLength: 10,
|
||||
error: 'Callsign must be 3-10 characters',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate user and return JWT token
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/login',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authenticateUser(body.email, body.password);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
password: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current user profile (requires authentication)
|
||||
*/
|
||||
.get('/api/auth/me', async ({ user, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Get full user data from database
|
||||
const userData = await getUserById(user.id);
|
||||
if (!userData) {
|
||||
set.status = 404;
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userData,
|
||||
};
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /api/auth/lotw-credentials
|
||||
* Update LoTW credentials (requires authentication)
|
||||
*/
|
||||
.put(
|
||||
'/api/auth/lotw-credentials',
|
||||
async ({ user, body, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'LoTW credentials updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update LoTW credentials',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
lotwUsername: t.String(),
|
||||
lotwPassword: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// Health check endpoint
|
||||
.get('/api/health', () => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Start server
|
||||
.listen(3001);
|
||||
|
||||
console.log(`🦊 Backend server running at http://localhost:${app.server?.port}`);
|
||||
console.log(`📡 API endpoints available at http://localhost:${app.server?.port}/api`);
|
||||
|
||||
export default app;
|
||||
26
src/backend/init-db.js
Normal file
26
src/backend/init-db.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import Database from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import * as schema from './db/schema/index.js';
|
||||
|
||||
const sqlite = new Database('./award.db');
|
||||
const db = drizzle({
|
||||
client: sqlite,
|
||||
schema,
|
||||
});
|
||||
|
||||
console.log('Creating database tables...');
|
||||
|
||||
// Use drizzle-kit to push the schema
|
||||
// Since we don't have migrations, let's use the push command
|
||||
const { execSync } = await import('child_process');
|
||||
|
||||
try {
|
||||
execSync('bun drizzle-kit push', {
|
||||
cwd: '/Users/joergdorgeist/Dev/award',
|
||||
stdio: 'inherit'
|
||||
});
|
||||
console.log('✓ Database initialized successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
166
src/backend/routes/auth.js
Normal file
166
src/backend/routes/auth.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import { t } from 'elysia';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
getUserById,
|
||||
updateLoTWCredentials,
|
||||
} from '../services/auth.service.js';
|
||||
|
||||
/**
|
||||
* Authentication routes
|
||||
* Provides endpoints for user registration, login, and profile management
|
||||
* These routes will be added to the main app which already has authMiddleware
|
||||
*/
|
||||
export const authRoutes = (app) => {
|
||||
console.error('authRoutes function called with app');
|
||||
return app
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Register a new user
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/register',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Create user
|
||||
const user = await registerUser(body);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
set.status = 201;
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({
|
||||
format: 'email',
|
||||
error: 'Invalid email address',
|
||||
}),
|
||||
password: t.String({
|
||||
minLength: 8,
|
||||
error: 'Password must be at least 8 characters',
|
||||
}),
|
||||
callsign: t.String({
|
||||
minLength: 3,
|
||||
maxLength: 10,
|
||||
error: 'Callsign must be 3-10 characters',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate user and return JWT token
|
||||
*/
|
||||
.post(
|
||||
'/api/auth/login',
|
||||
async ({ body, jwt, set }) => {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authenticateUser(body.email, body.password);
|
||||
|
||||
// Generate JWT token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 401;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
password: t.String(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current user profile (requires authentication)
|
||||
*/
|
||||
.get('/api/auth/me', async ({ user, set }) => {
|
||||
console.error('/me endpoint called, user:', user);
|
||||
if (!user) {
|
||||
console.error('No user in context - returning 401');
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Get full user data from database
|
||||
const userData = await getUserById(user.id);
|
||||
if (!userData) {
|
||||
set.status = 404;
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userData,
|
||||
};
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /api/auth/lotw-credentials
|
||||
* Update LoTW credentials (requires authentication)
|
||||
*/
|
||||
.put(
|
||||
'/api/auth/lotw-credentials',
|
||||
async ({ user, body, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'LoTW credentials updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update LoTW credentials',
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
lotwUsername: t.String(),
|
||||
lotwPassword: t.String(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
132
src/backend/services/auth.service.js
Normal file
132
src/backend/services/auth.service.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../config/database.js';
|
||||
import { users } from '../db/schema/index.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<string>} Hashed password
|
||||
*/
|
||||
export async function hashPassword(password) {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* @param {string} password - Plain text password
|
||||
* @param {string} hash - Hashed password
|
||||
* @returns {Promise<boolean>} True if password matches
|
||||
*/
|
||||
export async function verifyPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User email
|
||||
* @param {string} userData.password - Plain text password
|
||||
* @param {string} userData.callsign - Ham radio callsign
|
||||
* @returns {Promise<Object>} Created user object (without password)
|
||||
* @throws {Error} If email already exists
|
||||
*/
|
||||
export async function registerUser({ email, password, callsign }) {
|
||||
// Check if user already exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('Email already registered');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const newUser = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
callsign: callsign.toUpperCase(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Return user without password hash
|
||||
const { passwordHash: _, ...userWithoutPassword } = newUser[0];
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user with email and password
|
||||
* @param {string} email - User email
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<Object>} User object (without password) if authenticated
|
||||
* @throws {Error} If credentials are invalid
|
||||
*/
|
||||
export async function authenticateUser(email, password) {
|
||||
// Find user by email
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Return user without password hash
|
||||
const { passwordHash: _, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<Object|null>} User object (without password) or null
|
||||
*/
|
||||
export async function getUserById(userId) {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const { passwordHash: _, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's LoTW credentials (encrypted)
|
||||
* @param {number} userId - User ID
|
||||
* @param {string} lotwUsername - LoTW username
|
||||
* @param {string} lotwPassword - LoTW password (will be encrypted)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateLoTWCredentials(userId, lotwUsername, lotwPassword) {
|
||||
// Simple encryption for storage (in production, use a proper encryption library)
|
||||
// For now, we'll store as-is but marked for encryption
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lotwUsername,
|
||||
lotwPassword, // TODO: Encrypt before storing
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
23
src/frontend/.gitignore
vendored
Normal file
23
src/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
src/frontend/.npmrc
Normal file
1
src/frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
38
src/frontend/README.md
Normal file
38
src/frontend/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
215
src/frontend/bun.lock
Normal file
215
src/frontend/bun.lock
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"svelte": "^5.45.6",
|
||||
"vite": "^6.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
|
||||
|
||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.49.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.2.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"svelte": ["svelte@5.46.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
}
|
||||
}
|
||||
102
src/frontend/clear-cache.html
Normal file
102
src/frontend/clear-cache.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Clear Cache - Ham Radio Awards</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.box {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 { color: #333; margin-top: 0; }
|
||||
button {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover { background: #357abd; }
|
||||
.success {
|
||||
color: #27ae60;
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.info {
|
||||
background: #e8f4fd;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Browser Cache Reset</h1>
|
||||
<div class="info">
|
||||
<strong>The URI malformed error you saw is caused by your browser environment (extensions or cache), not the application code.</strong>
|
||||
<br><br>
|
||||
Since incognito mode works, try these steps to fix your normal browser:
|
||||
</div>
|
||||
|
||||
<button onclick="clearAll()">Clear All Data</button>
|
||||
<button onclick="hardRefresh()">Hard Refresh (Cmd+Shift+R)</button>
|
||||
<button onclick="goToApp()">Go to App</button>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<script>
|
||||
function clearAll() {
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear all cookies
|
||||
document.cookie.split(";").forEach(c => {
|
||||
document.cookie = c.trim().split("=")[0] +
|
||||
'=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/';
|
||||
});
|
||||
|
||||
// Clear caches
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => caches.delete(name));
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML =
|
||||
'<div class="success">✓ Cleared! Now click "Hard Refresh" or reload manually.</div>';
|
||||
}
|
||||
|
||||
function hardRefresh() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function goToApp() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="info" style="margin-top: 30px;">
|
||||
<strong>If the issue persists after clearing cache:</strong>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>Disable browser extensions (especially ad blockers, privacy tools)</li>
|
||||
<li>Try a different browser (Chrome, Firefox, Safari)</li>
|
||||
<li>Clear browser cache from DevTools → Application → Clear storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
13
src/frontend/jsconfig.json
Normal file
13
src/frontend/jsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
19
src/frontend/package.json
Normal file
19
src/frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"svelte": "^5.45.6",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
11
src/frontend/src/app.html
Normal file
11
src/frontend/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
138
src/frontend/src/lib/api.js
Normal file
138
src/frontend/src/lib/api.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* API base URL - change this to match your backend
|
||||
*/
|
||||
const API_BASE = 'http://localhost:3001/api';
|
||||
|
||||
/**
|
||||
* Make an API request
|
||||
* @param {string} endpoint - API endpoint (e.g., '/auth/login')
|
||||
* @param {Object} options - Fetch options
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
// Get token from localStorage (only in browser)
|
||||
let token = null;
|
||||
if (browser) {
|
||||
try {
|
||||
token = localStorage.getItem('auth_token');
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'API request failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication API
|
||||
*/
|
||||
export const authAPI = {
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User email
|
||||
* @param {string} userData.password - User password
|
||||
* @param {string} userData.callsign - Ham radio callsign
|
||||
* @returns {Promise<Object>} Registration response with token and user
|
||||
*/
|
||||
register: (userData) =>
|
||||
apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* @param {Object} credentials - Login credentials
|
||||
* @param {string} credentials.email - User email
|
||||
* @param {string} credentials.password - User password
|
||||
* @returns {Promise<Object>} Login response with token and user
|
||||
*/
|
||||
login: (credentials) =>
|
||||
apiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* @returns {Promise<Object>} User profile
|
||||
*/
|
||||
getProfile: () => apiRequest('/auth/me'),
|
||||
|
||||
/**
|
||||
* Update LoTW credentials
|
||||
* @param {Object} credentials - LoTW credentials
|
||||
* @param {string} credentials.lotwUsername - LoTW username
|
||||
* @param {string} credentials.lotwPassword - LoTW password
|
||||
* @returns {Promise<Object>} Update response
|
||||
*/
|
||||
updateLoTWCredentials: (credentials) =>
|
||||
apiRequest('/auth/lotw-credentials', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(credentials),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Awards API
|
||||
*/
|
||||
export const awardsAPI = {
|
||||
/**
|
||||
* Get all available awards
|
||||
* @returns {Promise<Object>} List of awards
|
||||
*/
|
||||
getAll: () => apiRequest('/awards'),
|
||||
|
||||
/**
|
||||
* Get user progress for a specific award
|
||||
* @param {string} awardId - Award ID
|
||||
* @returns {Promise<Object>} Award progress
|
||||
*/
|
||||
getProgress: (awardId) => apiRequest(`/awards/${awardId}/progress`),
|
||||
};
|
||||
|
||||
/**
|
||||
* QSOs API
|
||||
*/
|
||||
export const qsosAPI = {
|
||||
/**
|
||||
* Get user's QSOs
|
||||
* @param {Object} filters - Query filters
|
||||
* @returns {Promise<Object>} List of QSOs
|
||||
*/
|
||||
getAll: (filters = {}) => {
|
||||
const params = new URLSearchParams(filters);
|
||||
return apiRequest(`/qsos?${params}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sync QSOs from LoTW
|
||||
* @returns {Promise<Object>} Sync result
|
||||
*/
|
||||
syncFromLoTW: () =>
|
||||
apiRequest('/lotw/sync', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
1
src/frontend/src/lib/assets/favicon.svg
Normal file
1
src/frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/frontend/src/lib/index.js
Normal file
1
src/frontend/src/lib/index.js
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
221
src/frontend/src/lib/stores.js
Normal file
221
src/frontend/src/lib/stores.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { authAPI } from './api.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthState
|
||||
* @property {Object|null} user - Current user object
|
||||
* @property {string|null} token - JWT token
|
||||
* @property {boolean} loading - Loading state
|
||||
* @property {string|null} error - Error message
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely get item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @returns {string|null} Storage value or null
|
||||
*/
|
||||
function getStorageItem(key) {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely set item in localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {string} value - Storage value
|
||||
*/
|
||||
function setStorageItem(key, value) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely remove item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
*/
|
||||
function removeStorageItem(key) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authentication store
|
||||
* @returns {import('svelte/store').Writable<AuthState>}
|
||||
*/
|
||||
function createAuthStore() {
|
||||
// Initialize state (localStorage only accessed in browser)
|
||||
let initialState = {
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Only read from localStorage if in browser
|
||||
if (browser) {
|
||||
const token = getStorageItem('auth_token');
|
||||
const userJson = getStorageItem('auth_user');
|
||||
initialState = {
|
||||
user: userJson ? JSON.parse(userJson) : null,
|
||||
token: token || null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {import('svelte/store').Writable<AuthState>} */
|
||||
const { subscribe, set, update } = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email
|
||||
* @param {string} userData.password
|
||||
* @param {string} userData.callsign
|
||||
*/
|
||||
register: async (userData) => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authAPI.register(userData);
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('auth_token', response.token);
|
||||
setStorageItem('auth_user', JSON.stringify(response.user));
|
||||
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message,
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* @param {string} email - User email
|
||||
* @param {string} password - User password
|
||||
*/
|
||||
login: async (email, password) => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authAPI.login({ email, password });
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('auth_token', response.token);
|
||||
setStorageItem('auth_user', JSON.stringify(response.user));
|
||||
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message,
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
logout: () => {
|
||||
removeStorageItem('auth_token');
|
||||
removeStorageItem('auth_user');
|
||||
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Load user profile from API
|
||||
*/
|
||||
loadProfile: async () => {
|
||||
const token = getStorageItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
update((state) => ({ ...state, loading: true }));
|
||||
|
||||
try {
|
||||
const response = await authAPI.getProfile();
|
||||
|
||||
setStorageItem('auth_user', JSON.stringify(response.user));
|
||||
|
||||
update((state) => ({
|
||||
...state,
|
||||
user: response.user,
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
// If token is invalid, logout
|
||||
if (error.message.includes('Unauthorized')) {
|
||||
removeStorageItem('auth_token');
|
||||
removeStorageItem('auth_user');
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError: () => {
|
||||
update((state) => ({ ...state, error: null }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication store
|
||||
* @type {ReturnType<typeof createAuthStore>}
|
||||
*/
|
||||
export const auth = createAuthStore();
|
||||
65
src/frontend/src/routes/+layout.svelte
Normal file
65
src/frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="description" content="Track your ham radio award progress" />
|
||||
</svelte:head>
|
||||
|
||||
{#if browser}
|
||||
<div class="app">
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Ham Radio Awards. Track your DXCC, WAS, VUCC and more.</p>
|
||||
</footer>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="app">
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<footer>
|
||||
<p>© 2025 Ham Radio Awards. Track your DXCC, WAS, VUCC and more.</p>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #2c3e50;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
194
src/frontend/src/routes/+page.svelte
Normal file
194
src/frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores.js';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
onMount(() => {
|
||||
// Load user profile on mount if we have a token
|
||||
if (browser) {
|
||||
auth.loadProfile();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if $auth.user}
|
||||
<!-- Dashboard for logged-in users -->
|
||||
<div class="dashboard">
|
||||
<div class="welcome-section">
|
||||
<h1>Welcome back, {$auth.user.callsign}!</h1>
|
||||
<p>Track your ham radio award progress</p>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<div class="action-card">
|
||||
<h3>📋 Awards</h3>
|
||||
<p>View your award progress</p>
|
||||
<a href="/awards" class="btn">View Awards</a>
|
||||
</div>
|
||||
|
||||
<div class="action-card">
|
||||
<h3>📡 QSOs</h3>
|
||||
<p>Browse your logbook</p>
|
||||
<a href="/qsos" class="btn">View QSOs</a>
|
||||
</div>
|
||||
|
||||
<div class="action-card">
|
||||
<h3>⚙️ Settings</h3>
|
||||
<p>Manage LoTW credentials</p>
|
||||
<a href="/settings" class="btn">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<li>Configure your LoTW credentials in Settings</li>
|
||||
<li>Sync your QSOs from LoTW</li>
|
||||
<li>Track your award progress</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Welcome page for non-logged-in users -->
|
||||
<div class="welcome">
|
||||
<h1>Ham Radio Awards</h1>
|
||||
<p class="subtitle">Track your DXCC, WAS, VUCC and more</p>
|
||||
<div class="cta">
|
||||
<a href="/auth/login" class="btn btn-primary">Login</a>
|
||||
<a href="/auth/register" class="btn btn-secondary">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.welcome h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.welcome-section p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-card h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #357abd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.action-card .btn {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-card .btn:hover {
|
||||
background-color: #357abd;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #4a90e2;
|
||||
padding: 1.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-box ol {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
187
src/frontend/src/routes/auth/login/+page.svelte
Normal file
187
src/frontend/src/routes/auth/login/+page.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script>
|
||||
import { auth } from '$lib/stores.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
|
||||
async function handleLogin() {
|
||||
try {
|
||||
await auth.login(email, password);
|
||||
// Redirect to dashboard after successful login
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err.message || 'Login failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Ham Radio Awards</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1>Ham Radio Awards</h1>
|
||||
<p>Sign in to track your award progress</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleLogin} class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" disabled={!email || !password}>
|
||||
{#if auth.loading}
|
||||
Signing in...
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Don't have an account? <a href="/auth/register">Register</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #357abd;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: #4a90e2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
225
src/frontend/src/routes/auth/register/+page.svelte
Normal file
225
src/frontend/src/routes/auth/register/+page.svelte
Normal file
@@ -0,0 +1,225 @@
|
||||
<script>
|
||||
import { auth } from '$lib/stores.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let callsign = '';
|
||||
let error = '';
|
||||
|
||||
async function handleRegister() {
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.register({ email, password, callsign });
|
||||
// Redirect to dashboard after successful registration
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err.message || 'Registration failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - Ham Radio Awards</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1>Create Account</h1>
|
||||
<p>Register to track your ham radio awards</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleRegister} class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="callsign">Callsign</label>
|
||||
<input
|
||||
id="callsign"
|
||||
type="text"
|
||||
bind:value={callsign}
|
||||
placeholder="e.g., W1AW"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={!email || !password || !callsign || password !== confirmPassword}
|
||||
>
|
||||
{#if auth.loading}
|
||||
Creating account...
|
||||
{:else}
|
||||
Create Account
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Already have an account? <a href="/auth/login">Sign In</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #357abd;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: #4a90e2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
3
src/frontend/static/robots.txt
Normal file
3
src/frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
14
src/frontend/svelte.config.js
Normal file
14
src/frontend/svelte.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
// Disable origin checks in dev to prevent issues with browser extensions
|
||||
csrf: {
|
||||
trustedOrigins: process.env.NODE_ENV === 'production' ? undefined : ['http://localhost:5173', 'http://127.0.0.1:5173']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
43
src/frontend/vite.config.js
Normal file
43
src/frontend/vite.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Plugin to suppress URI malformed errors from browser extensions
|
||||
function suppressURIErrorPlugin() {
|
||||
return {
|
||||
name: 'suppress-uri-error',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// Intercept malformed requests before they reach Vite's middleware
|
||||
try {
|
||||
// Try to decode the URL to catch malformed URIs early
|
||||
if (req.url) {
|
||||
decodeURI(req.url);
|
||||
}
|
||||
next();
|
||||
} catch (e) {
|
||||
// Silently handle malformed URIs
|
||||
res.writeHead(400);
|
||||
res.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), suppressURIErrorPlugin()],
|
||||
server: {
|
||||
host: 'localhost',
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
allowedHosts: true,
|
||||
watch: {
|
||||
usePolling: true
|
||||
},
|
||||
// Disable error overlay to dismiss URI malformed errors
|
||||
hmr: {
|
||||
overlay: false
|
||||
}
|
||||
}
|
||||
});
|
||||
96
src/shared/types/awards.js
Normal file
96
src/shared/types/awards.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @typedef {Object} AwardDefinition
|
||||
* @property {string} id - Unique identifier: 'dxcc-mixed', 'was-cw'
|
||||
* @property {string} name - Display name
|
||||
* @property {string} description - Award description
|
||||
* @property {'dxcc' | 'was' | 'vucc' | 'iota' | 'custom'} category - Award category
|
||||
* @property {AwardRule} rules - Award rule configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {CounterRule | EntityRule | FilteredRule | CombinedRule} AwardRule
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CounterRule
|
||||
* @property {'counter'} type
|
||||
* @property {number} target - Required count
|
||||
* @property {'qso' | 'entity'} countBy - What to count
|
||||
* @property {FilterGroup} [filters] - Optional filters
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EntityRule
|
||||
* @property {'entity'} type
|
||||
* @property {'dxcc' | 'state' | 'grid' | 'zone'} entityType - Entity type to count
|
||||
* @property {number} target - Required unique entities
|
||||
* @property {FilterGroup} [filters] - Optional filters
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FilteredRule
|
||||
* @property {'filtered'} type
|
||||
* @property {AwardRule} baseRule - Base counter/entity rule
|
||||
* @property {FilterGroup} filters - Required filters
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CombinedRule
|
||||
* @property {'combined'} type
|
||||
* @property {'AND' | 'OR'} operator - Logical operator
|
||||
* @property {AwardRule[]} rules - Sub-rules
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FilterGroup
|
||||
* @property {'AND' | 'OR'} operator - Logical operator for filters
|
||||
* @property {Filter[]} filters - Array of filters
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {BandFilter | ModeFilter | DateRangeFilter | SatelliteFilter | EntityFilter} Filter
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BandFilter
|
||||
* @property {'band'} field
|
||||
* @property {'eq' | 'in'} operator
|
||||
* @property {string | string[]} value - '40m' or ['160m','80m','40m']
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ModeFilter
|
||||
* @property {'mode'} field
|
||||
* @property {'eq' | 'in'} operator
|
||||
* @property {string | string[]} value - 'CW' or ['CW','RTTY']
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DateRangeFilter
|
||||
* @property {'qsoDate'} field
|
||||
* @property {'range'} operator
|
||||
* @property {{from: string, to: string}} value - ADIF dates
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SatelliteFilter
|
||||
* @property {'satellite'} field
|
||||
* @property {'eq'} operator
|
||||
* @property {boolean} value - true = satellite QSOs only
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EntityFilter
|
||||
* @property {'entity' | 'continent' | 'cqZone' | 'state'} field
|
||||
* @property {'eq' | 'in' | 'notIn'} operator
|
||||
* @property {string | string[] | number | number[]} value
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AwardProgressResult
|
||||
* @property {number} worked - Worked count
|
||||
* @property {number} confirmed - Confirmed count
|
||||
* @property {number} target - Target count
|
||||
* @property {string[]} [entities] - Unique entities (for entity rules)
|
||||
* @property {AwardProgressResult[]} [breakdown] - Breakdown for combined rules
|
||||
*/
|
||||
Reference in New Issue
Block a user