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:
2026-01-15 11:01:10 +01:00
commit 8c26fc93e3
41 changed files with 3424 additions and 0 deletions

View 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();
}

View 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 };

View 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
View 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
View 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
View 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(),
}),
}
);
};

View 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
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

38
src/frontend/README.md Normal file
View 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
View 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=="],
}
}

View 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>

View 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
View 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
View 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
View 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',
}),
};

View 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

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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();

View 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>&copy; 2025 Ham Radio Awards. Track your DXCC, WAS, VUCC and more.</p>
</footer>
</div>
{:else}
<div class="app">
<main>
<slot />
</main>
<footer>
<p>&copy; 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View 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;

View 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
}
}
});

View 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
*/