Compare commits

..

15 Commits

Author SHA1 Message Date
b296514356 fix: use entityId for QSO stats entity counting to match DXCC award
Changed QSO page statistics to count entities using entity_id (numeric
DXCC code) instead of entity (text field) for consistency with DXCC
award progress calculations.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 18:33:54 +01:00
70858836d0 Spinner 2026-01-24 16:01:05 +01:00
257ebf6c5d fix: increase page max-width from 1200px to 1600px for better table display
Tables were too narrow and required horizontal scrolling on larger screens.
Increased max-width across all pages to better utilize available screen space.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:56:56 +01:00
caf7703073 fix: add missing updateUserRole import in admin service
The updateUserRole function was being called but not imported from
auth.service.js, causing "updateUserRole is not defined" error when
changing user roles via admin interface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:44:52 +01:00
fa6420d149 fix: use CSS variables for dark mode support in settings page
Replace hard-coded colors with CSS variables from the theme system to
properly support both light and dark modes. Also add proper input
styling and strong tag emphasis colors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:57:50 +01:00
aa55158347 feat: add WAE (Worked All Europe) award implementation
Implement DARC's WAE award with dual metrics tracking (countries + bandpoints).

Features:
- 54 European countries with correct DXCC entityIds from ARRL
- 8 WAE-specific entities (Shetland, Sicily, Sardinia, Crete, etc.)
- Bandpoints calculation: 1 pt/band (2 pts for 160m/80m), max 5 bands/country
- 5 award levels: WAE III (40/100), WAE II (50/150), WAE I (60/200),
  WAE TOP (70/300), WAE Trophy (all/365)
- Mode groups: CW, SSB, RTTY, FT8, Digi-Modes, Mixed-Mode
- Admin UI support for creating/editing WAE awards
- Award detail page with dual metrics display

Files:
- award-data/wae-country-list.json: WAE country definitions
- award-definitions/wae.json: Award configuration
- src/backend/services/awards.service.js: WAE calculation functions
- src/frontend: Admin and award detail views

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:07:52 +01:00
joerg
e4e7f3c208 Awawrdd 2026-01-23 14:18:56 +01:00
a35731f626 fix: use smart default for displayField based on entityType
When displayField is omitted in award definitions, the backend now
selects the appropriate default field based on the entityType:
- dxcc → entity (country name)
- state → state
- grid → grid (4-character)
- callsign → callsign

Previously, it used a fixed fallback order that prioritized entity
over other fields, causing grid-based awards to show DXCC names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 14:14:05 +01:00
2ae47232cb fix: improve dark mode contrast for Points and Target badges in award detail view
Replace inline styles with CSS classes that use semi-transparent
backgrounds in dark mode instead of bright solid colors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 14:07:23 +01:00
8b846bffbe docs: add super-admin role documentation
Add comprehensive documentation for the new super-admin role feature:

README.md:
- Update Users Table schema with isAdmin, isSuperAdmin, lastSeen fields
- Add Admin API section with all endpoints
- Add User Roles and Permissions section with security rules

docs/DOCUMENTATION.md:
- Update Users Table schema
- Add Admin System section with overview, roles, security rules
- Document all admin API endpoints
- Add audit logging details
- Include JWT token structure
- Add setup and deployment instructions

CLAUDE.md:
- Add Admin System and User Roles section
- Document admin service functions
- Include security rules
- Add JWT token claims structure
- Document frontend admin interface

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 13:53:48 +01:00
ed433902d9 feat: add super-admin role with admin impersonation support
Add a new super-admin role that can impersonate other admins. Regular
admins retain all existing permissions but cannot impersonate other
admins or promote users to super-admin.

Backend changes:
- Add isSuperAdmin field to users table with default false
- Add isSuperAdmin() check function to auth service
- Update JWT tokens to include isSuperAdmin claim
- Allow super-admins to impersonate other admins
- Add security rules for super-admin role changes

Frontend changes:
- Display "Super Admin" badge with gradient styling
- Add "Super Admin" option to role change modal
- Enable impersonate button for super-admins targeting admins
- Add "Super Admins Only" filter option

Security rules:
- Only super-admins can promote/demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 13:32:55 +01:00
a5f0e3b96f fix: include Alaska and Hawaii DXCC entities in WAS award
WAS award was only counting states in DXCC entity 291 (United States),
which excluded Alaska (DXCC 6) and Hawaii (DXCC 110). Updated filter to
use "in" operator with all three relevant DXCC entity IDs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 12:47:09 +01:00
b09e2b3ea2 feat: add achievements system to awards with mode filter support
Implement achievements milestone feature for awards with configurable
levels (Silver, Gold, Platinum, etc.) that track progress beyond the
base award target. Achievements display earned badges and progress bar
toward next level.

Backend:
- Add calculateAchievementProgress() helper in awards.service.js
- Include achievements field in getAllAwards() and getAwardById()
- Add achievements validation in awards-admin.service.js
- Update PUT endpoint validation schema to include achievements field

Frontend:
- Add achievements section to award detail page with gold badges
- Add reactive achievement progress calculation that respects mode filter
- Add achievements tab to admin create/edit pages with full CRUD

Award Definitions:
- Add achievements to DXCC (100/200/300/500)
- Add achievements to DLD (50/100/200/300)
- Add achievements to WAS (30/40/50)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 12:42:32 +01:00
239963ed89 feat: implement theme switching system with light and dark modes
Add complete theme switching system supporting Light Mode, Dark Mode, and
System preference (follows OS setting). Uses CSS custom properties for all
colors and Svelte store for state management with localStorage persistence.

New files:
- src/frontend/src/lib/stores/theme.js: Theme state management store
- src/frontend/src/app.css: CSS variables for light/dark themes
- src/frontend/src/lib/components/ThemeSwitcher.svelte: Theme switcher UI

Updated all components to use CSS variables instead of hardcoded colors:
- Main pages (home, awards, QSOs, settings, auth)
- Admin dashboard and award management pages
- Shared components (BackButton, ErrorDisplay, Loading)

Features:
- localStorage persistence for user preference
- System preference detection via matchMedia API
- FOUC prevention with inline script in app.html
- Smooth theme transitions across entire application

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 11:46:41 +01:00
d1e4c39ad6 feat: add last_seen tracking for users
Adds last_seen field to track when users last accessed the tool:
- Add lastSeen column to users table schema (nullable timestamp)
- Create migration to add last_seen column to existing databases
- Add updateLastSeen() function to auth.service.js
- Update auth derive middleware to update last_seen on each authenticated request (async, non-blocking)
- Add lastSeen to admin getUserStats() query for display in admin users table
- Add "Last Seen" column to admin users table in frontend

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:57:45 +01:00
44 changed files with 4645 additions and 764 deletions

View File

@@ -553,3 +553,76 @@ const params = new URLSearchParams({
**QSO Management**: **QSO Management**:
- Fixed DELETE /api/qsos/all to handle foreign key constraints - Fixed DELETE /api/qsos/all to handle foreign key constraints
- Added cache invalidation after QSO deletion - Added cache invalidation after QSO deletion
### Admin System and User Roles
The application supports three user roles with different permission levels:
**User Roles**:
- **Regular User**: View own QSOs, sync from LoTW/DCL, track award progress
- **Admin**: All user permissions + view system stats + manage users + impersonate regular users
- **Super Admin**: All admin permissions + promote/demote admins + impersonate admins
**Database Schema** (`src/backend/db/schema/index.js`):
- `isAdmin`: Boolean flag for admin users (default: false)
- `isSuperAdmin`: Boolean flag for super-admin users (default: false)
**Admin Service** (`src/backend/services/admin.service.js`):
- `isAdmin(userId)`: Check if user is admin
- `isSuperAdmin(userId)`: Check if user is super-admin
- `changeUserRole(adminId, targetUserId, newRole)`: Change user role ('user', 'admin', 'super-admin')
- `impersonateUser(adminId, targetUserId)`: Start impersonating a user
- `verifyImpersonation(token)`: Verify impersonation token validity
- `stopImpersonation(adminId, targetUserId)`: Stop impersonation
- `logAdminAction(adminId, actionType, targetUserId, details)`: Log admin actions
**Security Rules**:
1. Only super-admins can promote/demote super-admins
2. Regular admins cannot promote users to super-admin
3. Super-admins cannot demote themselves (prevents lockout)
4. Cannot demote the last super-admin
5. Regular admins can only impersonate regular users
6. Super-admins can impersonate any user (including other admins)
**Backend API Routes** (`src/backend/index.js`):
- `POST /api/admin/users/:userId/role`: Change user role
- Body: `{ "role": "user" | "admin" | "super-admin" }`
- `POST /api/admin/impersonate/:userId`: Start impersonating
- `POST /api/admin/impersonate/stop`: Stop impersonating
- `GET /api/admin/impersonation/status`: Check impersonation status
- `GET /api/admin/stats`: System statistics
- `GET /api/admin/users`: List all users
- `GET /api/admin/actions`: Admin action log
- `DELETE /api/admin/users/:userId`: Delete user
**JWT Token Claims**:
```javascript
{
userId: number,
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean, // Super-admin flag
impersonatedBy: number, // Present when impersonating
exp: number
}
```
**Frontend Admin Page** (`src/frontend/src/routes/admin/+page.svelte`):
- System statistics dashboard
- User management with filtering (all, super-admin, admin, user)
- Role change modal (user → admin → super-admin)
- Impersonate button (enabled for super-admins targeting admins)
- Admin action log viewing
**To create the first super-admin**:
1. Register a user account
2. Access database: `sqlite3 src/backend/award.db`
3. Run: `UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';`
4. Log out and log back in to get updated JWT token
**To promote via admin interface**:
1. Log in as existing super-admin
2. Navigate to `/admin`
3. Find user in Users tab
4. Click "Promote" and select "Super Admin"

View File

@@ -277,6 +277,52 @@ The application will be available at:
### Health ### Health
- `GET /api/health` - Health check endpoint - `GET /api/health` - Health check endpoint
### Admin API (Admin Only)
All admin endpoints require authentication and admin privileges.
- `GET /api/admin/stats` - Get system-wide statistics
- `GET /api/admin/users` - Get all users with statistics
- `GET /api/admin/users/:userId` - Get detailed information about a specific user
- `POST /api/admin/users/:userId/role` - Update user role (`user`, `admin`, `super-admin`)
- `DELETE /api/admin/users/:userId` - Delete a user
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
- `POST /api/admin/impersonate/stop` - Stop impersonating and return to admin account
- `GET /api/admin/impersonation/status` - Get current impersonation status
- `GET /api/admin/actions` - Get admin actions log
- `GET /api/admin/actions/my` - Get current admin's action log
### User Roles and Permissions
The application supports three user roles with different permission levels:
**Regular User**
- View own QSOs
- Sync from LoTW and DCL
- Track award progress
- Manage own credentials
**Admin**
- All user permissions
- View system statistics
- View all users
- Promote/demote regular users to/from admin
- Delete regular users
- Impersonate regular users (for support)
- View admin action log
**Super Admin**
- All admin permissions
- Promote/demote admins to/from super-admin
- Impersonate other admins (for support)
- Full access to all admin functions
**Security Rules:**
- Only super-admins can promote or demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin
## Database Schema ## Database Schema
### Users Table ### Users Table
@@ -289,6 +335,9 @@ CREATE TABLE users (
lotwUsername TEXT, lotwUsername TEXT,
lotwPassword TEXT, lotwPassword TEXT,
dclApiKey TEXT, -- DCL API key (for future use) dclApiKey TEXT, -- DCL API key (for future use)
isAdmin INTEGER DEFAULT 0 NOT NULL,
isSuperAdmin INTEGER DEFAULT 0 NOT NULL,
lastSeen INTEGER,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL updatedAt TEXT NOT NULL
); );

View File

@@ -0,0 +1,115 @@
{
"dxccBased": [
{ "entityId": 230, "country": "Germany", "prefix": "DL", "deleted": false },
{ "entityId": 227, "country": "France", "prefix": "F", "deleted": false },
{ "entityId": 248, "country": "Italy", "prefix": "I", "deleted": false },
{ "entityId": 223, "country": "England", "prefix": "G", "deleted": false },
{ "entityId": 279, "country": "Scotland", "prefix": "GM", "deleted": false },
{ "entityId": 265, "country": "Northern Ireland", "prefix": "GI", "deleted": false },
{ "entityId": 294, "country": "Wales", "prefix": "GW", "deleted": false },
{ "entityId": 114, "country": "Isle of Man", "prefix": "GD", "deleted": false },
{ "entityId": 122, "country": "Jersey", "prefix": "GJ", "deleted": false },
{ "entityId": 106, "country": "Guernsey", "prefix": "GU", "deleted": false },
{ "entityId": 236, "country": "Greece", "prefix": "SV", "deleted": false },
{ "entityId": 209, "country": "Belgium", "prefix": "ON", "deleted": false },
{ "entityId": 263, "country": "Netherlands", "prefix": "PA", "deleted": false },
{ "entityId": 287, "country": "Switzerland", "prefix": "HB", "deleted": false },
{ "entityId": 281, "country": "Spain", "prefix": "EA", "deleted": false },
{ "entityId": 272, "country": "Portugal", "prefix": "CT", "deleted": false },
{ "entityId": 206, "country": "Austria", "prefix": "OE", "deleted": false },
{ "entityId": 503, "country": "Czech Republic", "prefix": "OK", "deleted": false },
{ "entityId": 504, "country": "Slovakia", "prefix": "OM", "deleted": false },
{ "entityId": 239, "country": "Hungary", "prefix": "HA", "deleted": false },
{ "entityId": 269, "country": "Poland", "prefix": "SP", "deleted": false },
{ "entityId": 284, "country": "Sweden", "prefix": "SM", "deleted": false },
{ "entityId": 266, "country": "Norway", "prefix": "LA", "deleted": false },
{ "entityId": 221, "country": "Denmark", "prefix": "OZ", "deleted": false },
{ "entityId": 224, "country": "Finland", "prefix": "OH", "deleted": false },
{ "entityId": 52, "country": "Estonia", "prefix": "ES", "deleted": false },
{ "entityId": 145, "country": "Latvia", "prefix": "YL", "deleted": false },
{ "entityId": 146, "country": "Lithuania", "prefix": "LY", "deleted": false },
{ "entityId": 27, "country": "Belarus", "prefix": "EU", "deleted": false },
{ "entityId": 288, "country": "Ukraine", "prefix": "UR", "deleted": false },
{ "entityId": 179, "country": "Moldova", "prefix": "ER", "deleted": false },
{ "entityId": 275, "country": "Romania", "prefix": "YO", "deleted": false },
{ "entityId": 212, "country": "Bulgaria", "prefix": "LZ", "deleted": false },
{ "entityId": 296, "country": "Serbia", "prefix": "YT", "deleted": false },
{ "entityId": 497, "country": "Croatia", "prefix": "9A", "deleted": false },
{ "entityId": 499, "country": "Slovenia", "prefix": "S5", "deleted": false },
{ "entityId": 501, "country": "Bosnia and Herzegovina", "prefix": "E7", "deleted": false },
{ "entityId": 502, "country": "North Macedonia", "prefix": "Z3", "deleted": false },
{ "entityId": 7, "country": "Albania", "prefix": "ZA", "deleted": false },
{ "entityId": 514, "country": "Montenegro", "prefix": "4O", "deleted": false },
{ "entityId": 54, "country": "Russia (European)", "prefix": "UA", "deleted": false },
{ "entityId": 126, "country": "Kaliningrad", "prefix": "UA2", "deleted": false },
{ "entityId": 390, "country": "Turkey", "prefix": "TA", "deleted": false },
{ "entityId": 215, "country": "Cyprus", "prefix": "5B", "deleted": false },
{ "entityId": 257, "country": "Malta", "prefix": "9H", "deleted": false },
{ "entityId": 242, "country": "Iceland", "prefix": "TF", "deleted": false },
{ "entityId": 245, "country": "Ireland", "prefix": "EI", "deleted": false },
{ "entityId": 254, "country": "Luxembourg", "prefix": "LX", "deleted": false },
{ "entityId": 260, "country": "Monaco", "prefix": "3A", "deleted": false },
{ "entityId": 203, "country": "Andorra", "prefix": "C3", "deleted": false },
{ "entityId": 278, "country": "San Marino", "prefix": "T7", "deleted": false },
{ "entityId": 295, "country": "Vatican City", "prefix": "HV", "deleted": false },
{ "entityId": 251, "country": "Liechtenstein", "prefix": "HB0", "deleted": false }
],
"waeSpecific": [
{
"country": "Shetland Islands",
"prefix": "GM/S",
"callsigns": ["GM/S*", "GS/S*", "2M/S*"],
"parentDxcc": 279
},
{
"country": "European Turkey",
"prefix": "TA1",
"callsigns": ["TA1*"],
"parentDxcc": 390
},
{
"country": "Sardinia",
"prefix": "IS0",
"callsigns": ["IS0*"],
"parentDxcc": 248
},
{
"country": "Sicily",
"prefix": "IT9",
"callsigns": ["IT9*"],
"parentDxcc": 248
},
{
"country": "Corsica",
"prefix": "TK",
"callsigns": ["TK*"],
"parentDxcc": 227
},
{
"country": "Crete",
"prefix": "SV9",
"callsigns": ["SV9*", "J49*"],
"parentDxcc": 236
},
{
"country": "ITU Headquarters Geneva",
"prefix": "4U1I",
"callsigns": ["4U1I"],
"parentDxcc": null
},
{
"country": "UN Vienna",
"prefix": "4U1V",
"callsigns": ["4U1V"],
"parentDxcc": null
}
],
"deletedCountries": [
{
"country": "German Democratic Republic",
"prefix": "Y2",
"deleted": "1990-10-03",
"formerEntityId": 229
}
]
}

View File

@@ -4,16 +4,59 @@
"description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes", "description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.", "caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
"category": "darc", "category": "darc",
"modeGroups": {
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY"],
"Classic Digi-Modes": ["PSK31", "RTTY"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
},
"rules": { "rules": {
"type": "dok", "type": "dok",
"target": 100, "target": 100,
"confirmationType": "dcl", "confirmationType": "dcl",
"displayField": "darcDok" "displayField": "darcDok",
} "stations": []
} },
"modeGroups": {
"Digi-Modes": [
"FT4",
"FT8",
"MFSK",
"PSK31",
"RTTY"
],
"Classic Digi-Modes": [
"PSK31",
"RTTY"
],
"Mixed-Mode w/o WSJT-Modes": [
"AM",
"CW",
"FM",
"PSK31",
"RTTY",
"SSB"
],
"Phone-Modes": [
"AM",
"FM",
"SSB"
]
},
"achievements": [
{
"name": "DLD50",
"threshold": 50
},
{
"name": "DLD100",
"threshold": 100
},
{
"name": "DLD200",
"threshold": 200
},
{
"name": "DLD500",
"threshold": 500
},
{
"name": "DLD1000",
"threshold": 1000
}
]
}

View File

@@ -4,17 +4,71 @@
"description": "Confirm 100 DXCC entities on HF bands", "description": "Confirm 100 DXCC entities on HF bands",
"caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.", "caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc", "category": "dxcc",
"modeGroups": {
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
"Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
},
"rules": { "rules": {
"type": "entity", "type": "entity",
"entityType": "dxcc", "entityType": "dxcc",
"target": 100, "target": 100,
"displayField": "entity", "displayField": "entity",
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"] "allowed_bands": [
} "160m",
} "80m",
"60m",
"40m",
"30m",
"20m",
"17m",
"15m",
"12m",
"10m"
],
"stations": []
},
"modeGroups": {
"Digi-Modes": [
"FT4",
"FT8",
"JT65",
"JT9",
"MFSK",
"PSK31",
"RTTY"
],
"Classic Digi-Modes": [
"JT65",
"JT9",
"PSK31",
"RTTY"
],
"Mixed-Mode w/o WSJT-Modes": [
"AM",
"CW",
"FM",
"PSK31",
"RTTY",
"SSB"
],
"Phone-Modes": [
"AM",
"FM",
"SSB"
]
},
"achievements": [
{
"name": "Silver",
"threshold": 100
},
{
"name": "Gold",
"threshold": 200
},
{
"name": "Platinum",
"threshold": 300
},
{
"name": "All",
"threshold": 341
}
]
}

View File

@@ -9,14 +9,57 @@
"target": 50, "target": 50,
"countMode": "perBandMode", "countMode": "perBandMode",
"stations": [ "stations": [
{ "callsign": "DF2ET", "points": 10 }, {
{ "callsign": "DJ7NT", "points": 10 }, "callsign": "DF2ET",
{ "callsign": "HB9HIL", "points": 10 }, "points": 10
{ "callsign": "LA8AJA", "points": 10 }, },
{ "callsign": "DB4SCW", "points": 5 }, {
{ "callsign": "DG2RON", "points": 5 }, "callsign": "DJ7NT",
{ "callsign": "DG0TM", "points": 5 }, "points": 10
{ "callsign": "DO8MKR", "points": 5 } },
{
"callsign": "HB9HIL",
"points": 10
},
{
"callsign": "LA8AJA",
"points": 10
},
{
"callsign": "DB4SCW",
"points": 5
},
{
"callsign": "DG2RON",
"points": 5
},
{
"callsign": "DG0TM",
"points": 5
},
{
"callsign": "DO8MKR",
"points": 5
}
] ]
} },
} "modeGroups": {},
"achievements": [
{
"name": "Unicorn",
"threshold": 10
},
{
"name": "Few Devs",
"threshold": 20
},
{
"name": "More Devs",
"threshold": 40
},
{
"name": "Gold",
"threshold": 50
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "vucc6m",
"name": "VUCC 6M",
"description": "Shows confirmed gridsquares on 6M",
"caption": "Shows confirmed gridsquares on 6M",
"category": "vucc",
"rules": {
"type": "entity",
"satellite_only": false,
"filters": {
"operator": "AND",
"filters": [
{
"field": "band",
"operator": "eq",
"value": "6m"
}
]
},
"entityType": "grid",
"countMode": "perStation",
"target": 100,
"allowed_bands": [
"6m"
],
"stations": [],
"displayField": "grid"
},
"modeGroups": {},
"achievements": []
}

View File

@@ -0,0 +1,33 @@
{
"id": "wae",
"name": "WAE",
"description": "Worked All Europe - Contact and confirm European countries from the WAE Country List",
"caption": "Worked All Europe Award. Bandpoints: 1 point per band (2 points for 80m/160m), maximum 5 bands per country. Available in multiple mode variants.",
"category": "darc",
"modeGroups": {
"CW": ["CW"],
"SSB": ["SSB", "AM", "FM"],
"RTTY": ["RTTY"],
"FT8": ["FT8"],
"Digi-Modes": ["FT4", "FT8", "JT65", "JT9", "MFSK", "PSK31", "RTTY"],
"Classic Digi-Modes": ["PSK31", "RTTY"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
},
"rules": {
"type": "wae",
"targetCountries": 40,
"targetBandpoints": 100,
"doublePointBands": ["160m", "80m"],
"maxBandsPerCountry": 5,
"excludeDeletedForTop": true,
"waeCountryList": "wae-country-list.json"
},
"achievements": [
{ "name": "WAE III", "thresholdCountries": 40, "thresholdBandpoints": 100 },
{ "name": "WAE II", "thresholdCountries": 50, "thresholdBandpoints": 150 },
{ "name": "WAE I", "thresholdCountries": 60, "thresholdBandpoints": 200 },
{ "name": "WAE TOP", "thresholdCountries": 70, "thresholdBandpoints": 300, "excludeDeleted": true },
{ "name": "WAE Trophy", "thresholdCountries": 999, "thresholdBandpoints": 365, "requireAllCountries": true }
]
}

View File

@@ -1,6 +1,6 @@
{ {
"id": "was-mixed", "id": "was-mixed",
"name": "WAS Mixed Mode", "name": "WAS",
"description": "Confirm all 50 US states", "description": "Confirm all 50 US states",
"caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.", "caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "was", "category": "was",
@@ -14,10 +14,22 @@
"filters": [ "filters": [
{ {
"field": "entityId", "field": "entityId",
"operator": "eq", "operator": "in",
"value": 291 "value": [
291,
6,
110
]
} }
] ]
},
"stations": []
},
"modeGroups": {},
"achievements": [
{
"name": "WAS Award",
"threshold": 50
} }
} ]
} }

View File

@@ -276,6 +276,9 @@ award/
callsign: text (not null) callsign: text (not null)
lotwUsername: text (nullable) lotwUsername: text (nullable)
lotwPassword: text (nullable, encrypted) lotwPassword: text (nullable, encrypted)
isAdmin: boolean (default: false)
isSuperAdmin: boolean (default: false)
lastSeen: timestamp (nullable)
createdAt: timestamp createdAt: timestamp
updatedAt: timestamp updatedAt: timestamp
} }
@@ -1034,7 +1037,197 @@ When adding new awards or modifying the award system:
--- ---
## Resources ## Admin System
### Overview
The admin system provides user management, role-based access control, and account impersonation capabilities for support and administrative purposes.
### User Roles
The application supports three user roles with increasing permissions:
#### Regular User
- View own QSOs and statistics
- Sync from LoTW and DCL
- Track award progress
- Manage own credentials (LoTW, DCL)
#### Admin
- All user permissions
- View system-wide statistics
- View all users and their activity
- Promote/demote regular users to/from admin role
- Delete regular users
- Impersonate regular users (for support)
- View admin action log
#### Super Admin
- All admin permissions
- Promote/demote admins to/from super-admin role
- Impersonate other admins (for support)
- Cannot be demoted by regular admins
- Protected from accidental lockout
### Security Rules
**Role Change Restrictions:**
- Only super-admins can promote or demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin (prevents lockout)
**Impersonation Restrictions:**
- Regular admins can only impersonate regular users
- Super-admins can impersonate any user (including other admins)
- All impersonation actions are logged to audit trail
- Impersonation tokens expire after 1 hour
### Admin API Endpoints
**Statistics and Monitoring:**
- `GET /api/admin/stats` - System-wide statistics (users, QSOs, jobs)
- `GET /api/admin/users` - List all users with statistics
- `GET /api/admin/users/:userId` - Get detailed user information
- `GET /api/admin/actions` - View admin action log
- `GET /api/admin/actions/my` - View current admin's actions
**User Management:**
- `POST /api/admin/users/:userId/role` - Change user role
- Body: `{ "role": "user" | "admin" | "super-admin" }`
- `DELETE /api/admin/users/:userId` - Delete a user
**Impersonation:**
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
- `POST /api/admin/impersonate/stop` - Stop impersonation
- `GET /api/admin/impersonation/status` - Check impersonation status
### Admin Service
**File:** `src/backend/services/admin.service.js`
**Key Functions:**
```javascript
// Check user permissions
await isAdmin(userId)
await isSuperAdmin(userId)
// Role management
await changeUserRole(adminId, targetUserId, newRole)
// Impersonation
await impersonateUser(adminId, targetUserId)
await verifyImpersonation(impersonationToken)
await stopImpersonation(adminId, targetUserId)
// Audit logging
await logAdminAction(adminId, actionType, targetUserId, details)
```
### Audit Logging
All admin actions are logged to the `admin_actions` table for audit purposes:
**Action Types:**
- `impersonate_start` - Started impersonating a user
- `impersonate_stop` - Stopped impersonation
- `role_change` - Changed user role
- `user_delete` - Deleted a user
**Log Entry Structure:**
```javascript
{
id: integer,
adminId: integer,
actionType: string,
targetUserId: integer (nullable),
details: string (JSON),
createdAt: timestamp
}
```
### Frontend Admin Interface
**Route:** `/admin` (admin only)
**Features:**
- **Overview Tab:** System statistics dashboard
- **Users Tab:** User management with filtering
- **Awards Tab:** Award definition management
- **Action Log Tab:** Audit trail of admin actions
**User Management Actions:**
- **Impersonate** - Switch to user account (disabled for admins unless super-admin)
- **Promote/Demote** - Change user role
- **Delete** - Remove user and all associated data
### JWT Token Claims
Admin tokens include additional claims:
```javascript
{
userId: number,
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean, // New: Super-admin flag
exp: number
}
```
**Impersonation Token:**
```javascript
{
userId: number, // Target user ID
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean,
impersonatedBy: number, // Admin ID who started impersonation
exp: number // 1 hour expiration
}
```
### Setup
**To create the first super-admin:**
1. Register a user account normally
2. Access the database directly:
```bash
sqlite3 src/backend/award.db
```
3. Update the user to super-admin:
```sql
UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';
```
4. Log out and log back in to get the updated JWT token
**To promote users via the admin interface:**
1. Log in as a super-admin
2. Navigate to `/admin`
3. Find the user in the Users tab
4. Click "Promote" and select "Super Admin"
### Production Deployment
After pulling the latest code:
```bash
# Apply database migration (adds is_super_admin column)
sqlite3 src/backend/award.db "ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0 NOT NULL;"
# Restart backend
pm2 restart award-backend
# Promote a user to super-admin via database or existing admin interface
```
---
- [ARRL LoTW](https://lotw.arrl.org/) - [ARRL LoTW](https://lotw.arrl.org/)
- [DARC Community Logbook (DCL)](https://dcl.darc.de/) - [DARC Community Logbook (DCL)](https://dcl.darc.de/)

View File

@@ -0,0 +1,17 @@
CREATE TABLE `auto_sync_settings` (
`user_id` integer PRIMARY KEY NOT NULL,
`lotw_enabled` integer DEFAULT false NOT NULL,
`lotw_interval_hours` integer DEFAULT 24 NOT NULL,
`lotw_last_sync_at` integer,
`lotw_next_sync_at` integer,
`dcl_enabled` integer DEFAULT false NOT NULL,
`dcl_interval_hours` integer DEFAULT 24 NOT NULL,
`dcl_last_sync_at` integer,
`dcl_next_sync_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `users` ADD `is_super_admin` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `last_seen` integer;

View File

@@ -0,0 +1,868 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0d928d09-61c6-4311-beb8-0f597172e510",
"prevId": "071c98fb-6721-4da7-98cb-c16cb6aaf0c1",
"tables": {
"admin_actions": {
"name": "admin_actions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"admin_id": {
"name": "admin_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action_type": {
"name": "action_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_user_id": {
"name": "target_user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"admin_actions_admin_id_users_id_fk": {
"name": "admin_actions_admin_id_users_id_fk",
"tableFrom": "admin_actions",
"tableTo": "users",
"columnsFrom": [
"admin_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"admin_actions_target_user_id_users_id_fk": {
"name": "admin_actions_target_user_id_users_id_fk",
"tableFrom": "admin_actions",
"tableTo": "users",
"columnsFrom": [
"target_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"auto_sync_settings": {
"name": "auto_sync_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lotw_enabled": {
"name": "lotw_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"lotw_interval_hours": {
"name": "lotw_interval_hours",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 24
},
"lotw_last_sync_at": {
"name": "lotw_last_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_next_sync_at": {
"name": "lotw_next_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_enabled": {
"name": "dcl_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dcl_interval_hours": {
"name": "dcl_interval_hours",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 24
},
"dcl_last_sync_at": {
"name": "dcl_last_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_next_sync_at": {
"name": "dcl_next_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"auto_sync_settings_user_id_users_id_fk": {
"name": "auto_sync_settings_user_id_users_id_fk",
"tableFrom": "auto_sync_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"award_progress": {
"name": "award_progress",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"award_id": {
"name": "award_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worked_count": {
"name": "worked_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"confirmed_count": {
"name": "confirmed_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_required": {
"name": "total_required",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worked_entities": {
"name": "worked_entities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"confirmed_entities": {
"name": "confirmed_entities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_calculated_at": {
"name": "last_calculated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_qso_sync_at": {
"name": "last_qso_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"award_progress_user_id_users_id_fk": {
"name": "award_progress_user_id_users_id_fk",
"tableFrom": "award_progress",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"award_progress_award_id_awards_id_fk": {
"name": "award_progress_award_id_awards_id_fk",
"tableFrom": "award_progress",
"tableTo": "awards",
"columnsFrom": [
"award_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"awards": {
"name": "awards",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"qso_changes": {
"name": "qso_changes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"job_id": {
"name": "job_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"qso_id": {
"name": "qso_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"change_type": {
"name": "change_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"before_data": {
"name": "before_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"after_data": {
"name": "after_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"qso_changes_job_id_sync_jobs_id_fk": {
"name": "qso_changes_job_id_sync_jobs_id_fk",
"tableFrom": "qso_changes",
"tableTo": "sync_jobs",
"columnsFrom": [
"job_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"qso_changes_qso_id_qsos_id_fk": {
"name": "qso_changes_qso_id_qsos_id_fk",
"tableFrom": "qso_changes",
"tableTo": "qsos",
"columnsFrom": [
"qso_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"qsos": {
"name": "qsos",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"qso_date": {
"name": "qso_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_on": {
"name": "time_on",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"band": {
"name": "band",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"freq": {
"name": "freq",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"freq_rx": {
"name": "freq_rx",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity": {
"name": "entity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grid": {
"name": "grid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grid_source": {
"name": "grid_source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"continent": {
"name": "continent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cq_zone": {
"name": "cq_zone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"itu_zone": {
"name": "itu_zone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"county": {
"name": "county",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sat_name": {
"name": "sat_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sat_mode": {
"name": "sat_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"my_darc_dok": {
"name": "my_darc_dok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"darc_dok": {
"name": "darc_dok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_qsl_rdate": {
"name": "lotw_qsl_rdate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_qsl_rstatus": {
"name": "lotw_qsl_rstatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_qsl_rdate": {
"name": "dcl_qsl_rdate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_qsl_rstatus": {
"name": "dcl_qsl_rstatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_synced_at": {
"name": "lotw_synced_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"qsos_user_id_users_id_fk": {
"name": "qsos_user_id_users_id_fk",
"tableFrom": "qsos",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_jobs": {
"name": "sync_jobs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sync_jobs_user_id_users_id_fk": {
"name": "sync_jobs_user_id_users_id_fk",
"tableFrom": "sync_jobs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lotw_username": {
"name": "lotw_username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_password": {
"name": "lotw_password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_api_key": {
"name": "dcl_api_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_super_admin": {
"name": "is_super_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"last_seen": {
"name": "last_seen",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1768989260562, "when": 1768989260562,
"tag": "0003_tired_warpath", "tag": "0003_tired_warpath",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1769171258085,
"tag": "0004_overrated_havok",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,6 +10,8 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string|null} lotwPassword * @property {string|null} lotwPassword
* @property {string|null} dclApiKey * @property {string|null} dclApiKey
* @property {boolean} isAdmin * @property {boolean} isAdmin
* @property {boolean} isSuperAdmin
* @property {Date|null} lastSeen
* @property {Date} createdAt * @property {Date} createdAt
* @property {Date} updatedAt * @property {Date} updatedAt
*/ */
@@ -23,6 +25,8 @@ export const users = sqliteTable('users', {
lotwPassword: text('lotw_password'), // Encrypted lotwPassword: text('lotw_password'), // Encrypted
dclApiKey: text('dcl_api_key'), // DCL API key for future use dclApiKey: text('dcl_api_key'), // DCL API key for future use
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
isSuperAdmin: integer('is_super_admin', { mode: 'boolean' }).notNull().default(false),
lastSeen: integer('last_seen', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });

View File

@@ -12,6 +12,7 @@ import {
getUserById, getUserById,
updateLoTWCredentials, updateLoTWCredentials,
updateDCLCredentials, updateDCLCredentials,
updateLastSeen,
} from './services/auth.service.js'; } from './services/auth.service.js';
import { import {
getSystemStats, getSystemStats,
@@ -207,6 +208,14 @@ const app = new Elysia()
return { user: null }; return { user: null };
} }
// Update last_seen timestamp asynchronously (don't await)
updateLastSeen(payload.userId).catch((err) => {
// Silently fail - last_seen update failure shouldn't block requests
if (LOG_LEVEL === 'debug') {
logger.warn('Failed to update last_seen', { error: err.message });
}
});
// Check if this is an impersonation token // Check if this is an impersonation token
const isImpersonation = !!payload.impersonatedBy; const isImpersonation = !!payload.impersonatedBy;
@@ -216,6 +225,7 @@ const app = new Elysia()
email: payload.email, email: payload.email,
callsign: payload.callsign, callsign: payload.callsign,
isAdmin: payload.isAdmin, isAdmin: payload.isAdmin,
isSuperAdmin: payload.isSuperAdmin,
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
}, },
isImpersonation, isImpersonation,
@@ -351,6 +361,8 @@ const app = new Elysia()
userId: user.id, userId: user.id,
email: user.email, email: user.email,
callsign: user.callsign, callsign: user.callsign,
isAdmin: user.isAdmin,
isSuperAdmin: user.isSuperAdmin,
exp, exp,
}); });
@@ -420,6 +432,7 @@ const app = new Elysia()
email: user.email, email: user.email,
callsign: user.callsign, callsign: user.callsign,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isSuperAdmin: user.isSuperAdmin,
exp, exp,
}); });
@@ -1200,7 +1213,7 @@ const app = new Elysia()
/** /**
* POST /api/admin/users/:userId/role * POST /api/admin/users/:userId/role
* Update user admin status (admin only) * Update user role (admin only)
*/ */
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => { .post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) { if (!user || !user.isAdmin) {
@@ -1214,21 +1227,27 @@ const app = new Elysia()
return { success: false, error: 'Invalid user ID' }; return { success: false, error: 'Invalid user ID' };
} }
const { isAdmin } = body; const { role } = body;
if (typeof isAdmin !== 'boolean') { if (typeof role !== 'string') {
set.status = 400; set.status = 400;
return { success: false, error: 'isAdmin (boolean) is required' }; return { success: false, error: 'role (string) is required' };
}
const validRoles = ['user', 'admin', 'super-admin'];
if (!validRoles.includes(role)) {
set.status = 400;
return { success: false, error: `Invalid role. Must be one of: ${validRoles.join(', ')}` };
} }
try { try {
await changeUserRole(user.id, targetUserId, isAdmin); await changeUserRole(user.id, targetUserId, role);
return { return {
success: true, success: true,
message: 'User admin status updated successfully', message: 'User role updated successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Error updating user admin status', { error: error.message, userId: user.id }); logger.error('Error updating user role', { error: error.message, userId: user.id });
set.status = 400; set.status = 400;
return { return {
success: false, success: false,
@@ -1295,6 +1314,7 @@ const app = new Elysia()
email: targetUser.email, email: targetUser.email,
callsign: targetUser.callsign, callsign: targetUser.callsign,
isAdmin: targetUser.isAdmin, isAdmin: targetUser.isAdmin,
isSuperAdmin: targetUser.isSuperAdmin,
impersonatedBy: user.id, // Admin ID who started impersonation impersonatedBy: user.id, // Admin ID who started impersonation
exp, exp,
}); });
@@ -1355,6 +1375,7 @@ const app = new Elysia()
email: adminUser.email, email: adminUser.email,
callsign: adminUser.callsign, callsign: adminUser.callsign,
isAdmin: adminUser.isAdmin, isAdmin: adminUser.isAdmin,
isSuperAdmin: adminUser.isSuperAdmin,
exp, exp,
}); });
@@ -1598,6 +1619,7 @@ const app = new Elysia()
category: t.String(), category: t.String(),
rules: t.Any(), rules: t.Any(),
modeGroups: t.Optional(t.Any()), modeGroups: t.Optional(t.Any()),
achievements: t.Optional(t.Any()),
}), }),
} }
) )

View File

@@ -0,0 +1,86 @@
/**
* Migration: Add last_seen column to users table
*
* This script adds the last_seen column to track when users last accessed the tool.
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add last_seen column...');
try {
// Check if last_seen column already exists
const columnExists = sqlite.query(`
SELECT COUNT(*) as count
FROM pragma_table_info('users')
WHERE name='last_seen'
`).get();
if (columnExists.count > 0) {
console.log('Column last_seen already exists. Skipping...');
} else {
// Add last_seen column
sqlite.exec(`
ALTER TABLE users
ADD COLUMN last_seen INTEGER
`);
console.log('Added last_seen column to users table');
}
console.log('Migration complete! last_seen column added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
async function rollback() {
console.log('Starting rollback: Remove last_seen column...');
try {
// SQLite doesn't support DROP COLUMN directly before version 3.35.5
// For older versions, we need to recreate the table
console.log('Note: SQLite does not support DROP COLUMN. Manual cleanup required.');
console.log('To rollback: Recreate users table without last_seen column');
// For SQLite 3.35.5+, you can use:
// sqlite.exec(`ALTER TABLE users DROP COLUMN last_seen`);
console.log('Rollback note issued.');
} catch (error) {
console.error('Rollback failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Check if this is a rollback
const args = process.argv.slice(2);
if (args.includes('--rollback') || args.includes('-r')) {
rollback().then(() => {
console.log('Rollback script completed');
process.exit(0);
});
} else {
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});
}

View File

@@ -1,7 +1,7 @@
import { eq, sql, desc } from 'drizzle-orm'; import { eq, sql, desc } from 'drizzle-orm';
import { db, sqlite, logger } from '../config.js'; import { db, sqlite, logger } from '../config.js';
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js'; import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
import { getUserByIdFull, isAdmin } from './auth.service.js'; import { getUserByIdFull, isAdmin, isSuperAdmin, updateUserRole } from './auth.service.js';
/** /**
* Log an admin action for audit trail * Log an admin action for audit trail
@@ -127,6 +127,7 @@ export async function getUserStats() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`, qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
@@ -144,10 +145,13 @@ export async function getUserStats() {
.groupBy(users.id) .groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`); .orderBy(sql`COUNT(${qsos.id}) DESC`);
// Convert lastSync timestamps (seconds) to Date objects for JSON serialization // Convert timestamps (seconds) to Date objects for JSON serialization
// Note: lastSeen from Drizzle is already a Date object (timestamp mode)
// lastSync is raw SQL returning seconds, needs conversion
return stats.map(stat => ({ return stats.map(stat => ({
...stat, ...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null, lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
// lastSeen is already a Date object from Drizzle, don't convert
})); }));
} }
@@ -156,7 +160,7 @@ export async function getUserStats() {
* @param {number} adminId - Admin user ID * @param {number} adminId - Admin user ID
* @param {number} targetUserId - Target user ID to impersonate * @param {number} targetUserId - Target user ID to impersonate
* @returns {Promise<Object>} Target user object * @returns {Promise<Object>} Target user object
* @throws {Error} If not admin or trying to impersonate another admin * @throws {Error} If not admin or trying to impersonate another admin (without super-admin)
*/ */
export async function impersonateUser(adminId, targetUserId) { export async function impersonateUser(adminId, targetUserId) {
// Verify the requester is an admin // Verify the requester is an admin
@@ -171,9 +175,17 @@ export async function impersonateUser(adminId, targetUserId) {
throw new Error('Target user not found'); throw new Error('Target user not found');
} }
// Check if target is also an admin (prevent admin impersonation) // Check if target is also an admin
if (targetUser.isAdmin) { if (targetUser.isAdmin) {
throw new Error('Cannot impersonate another admin user'); // Only super-admins can impersonate other admins
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
if (!requesterIsSuperAdmin) {
throw new Error('Cannot impersonate another admin user (super-admin required)');
}
// Prevent self-impersonation (edge case)
if (adminId === targetUserId) {
throw new Error('Cannot impersonate yourself');
}
} }
// Log impersonation action // Log impersonation action
@@ -267,48 +279,69 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
* Update user admin status (admin operation) * Update user admin status (admin operation)
* @param {number} adminId - Admin user ID making the change * @param {number} adminId - Admin user ID making the change
* @param {number} targetUserId - User ID to update * @param {number} targetUserId - User ID to update
* @param {boolean} newIsAdmin - New admin flag * @param {string} newRole - New role: 'user', 'admin', or 'super-admin'
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If not admin or would remove last admin * @throws {Error} If not admin or violates security rules
*/ */
export async function changeUserRole(adminId, targetUserId, newIsAdmin) { export async function changeUserRole(adminId, targetUserId, newRole) {
// Validate role
const validRoles = ['user', 'admin', 'super-admin'];
if (!validRoles.includes(newRole)) {
throw new Error('Invalid role. Must be one of: user, admin, super-admin');
}
// Verify the requester is an admin // Verify the requester is an admin
const requesterIsAdmin = await isAdmin(adminId); const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) { if (!requesterIsAdmin) {
throw new Error('Only admins can change user admin status'); throw new Error('Only admins can change user roles');
} }
// Get requester super-admin status
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
// Get target user // Get target user
const targetUser = await getUserByIdFull(targetUserId); const targetUser = await getUserByIdFull(targetUserId);
if (!targetUser) { if (!targetUser) {
throw new Error('Target user not found'); throw new Error('Target user not found');
} }
// If demoting from admin, check if this would remove the last admin // Security rules for super-admin role changes
if (targetUser.isAdmin && !newIsAdmin) { const targetWillBeSuperAdmin = newRole === 'super-admin';
const adminCount = await db const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin;
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
.from(users)
.where(eq(users.isAdmin, 1));
if (adminCount[0].count === 1) { // Only super-admins can promote/demote super-admins
throw new Error('Cannot demote the last admin user'); if (targetWillBeSuperAdmin || targetIsCurrentlySuperAdmin) {
if (!requesterIsSuperAdmin) {
throw new Error('Only super-admins can promote or demote super-admins');
} }
} }
// Update admin status // Prevent self-demotion (super-admins cannot demote themselves)
await db if (adminId === targetUserId) {
.update(users) if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
.set({ throw new Error('Cannot demote yourself from super-admin');
isAdmin: newIsAdmin ? 1 : 0, }
updatedAt: new Date(), }
})
.where(eq(users.id, targetUserId)); // Cannot demote the last super-admin
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
const superAdminCount = await db
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
.from(users)
.where(eq(users.isSuperAdmin, 1));
if (superAdminCount[0].count === 1) {
throw new Error('Cannot demote the last super-admin');
}
}
// Update role (use the auth service function)
await updateUserRole(targetUserId, newRole);
// Log action // Log action
await logAdminAction(adminId, 'role_change', targetUserId, { await logAdminAction(adminId, 'role_change', targetUserId, {
oldIsAdmin: targetUser.isAdmin, oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'),
newIsAdmin: newIsAdmin, newRole: newRole,
}); });
} }

View File

@@ -158,6 +158,21 @@ export async function isAdmin(userId) {
return user?.isAdmin === true || user?.isAdmin === 1; return user?.isAdmin === true || user?.isAdmin === 1;
} }
/**
* Check if user is super-admin
* @param {number} userId - User ID
* @returns {Promise<boolean>} True if user is super-admin
*/
export async function isSuperAdmin(userId) {
const [user] = await db
.select({ isSuperAdmin: users.isSuperAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user?.isSuperAdmin === true || user?.isSuperAdmin === 1;
}
/** /**
* Get all admin users * Get all admin users
* @returns {Promise<Array>} Array of admin users (without passwords) * @returns {Promise<Array>} Array of admin users (without passwords)
@@ -178,16 +193,20 @@ export async function getAdminUsers() {
} }
/** /**
* Update user admin status * Update user role
* @param {number} userId - User ID * @param {number} userId - User ID
* @param {boolean} isAdmin - Admin flag * @param {string} role - Role: 'user', 'admin', or 'super-admin'
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function updateUserRole(userId, isAdmin) { export async function updateUserRole(userId, role) {
const isAdmin = role === 'admin' || role === 'super-admin';
const isSuperAdmin = role === 'super-admin';
await db await db
.update(users) .update(users)
.set({ .set({
isAdmin: isAdmin ? 1 : 0, isAdmin: isAdmin ? 1 : 0,
isSuperAdmin: isSuperAdmin ? 1 : 0,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
@@ -204,6 +223,8 @@ export async function getAllUsers() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
isSuperAdmin: users.isSuperAdmin,
lastSeen: users.lastSeen,
createdAt: users.createdAt, createdAt: users.createdAt,
updatedAt: users.updatedAt, updatedAt: users.updatedAt,
}) })
@@ -225,6 +246,7 @@ export async function getUserByIdFull(userId) {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
isSuperAdmin: users.isSuperAdmin,
lotwUsername: users.lotwUsername, lotwUsername: users.lotwUsername,
dclApiKey: users.dclApiKey, dclApiKey: users.dclApiKey,
createdAt: users.createdAt, createdAt: users.createdAt,
@@ -236,3 +258,17 @@ export async function getUserByIdFull(userId) {
return user || null; return user || null;
} }
/**
* Update user's last seen timestamp
* @param {number} userId - User ID
* @returns {Promise<void>}
*/
export async function updateLastSeen(userId) {
await db
.update(users)
.set({
lastSeen: new Date(),
})
.where(eq(users.id, userId));
}

View File

@@ -134,6 +134,29 @@ export function validateAwardDefinition(definition, existingDefinitions = []) {
errors.push('Category must be a string'); errors.push('Category must be a string');
} }
// Validate achievements if present
if (definition.achievements) {
if (!Array.isArray(definition.achievements)) {
errors.push('achievements must be an array');
} else {
for (let i = 0; i < definition.achievements.length; i++) {
const achievement = definition.achievements[i];
if (!achievement.name || typeof achievement.name !== 'string') {
errors.push(`Achievement ${i + 1} must have a name`);
}
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
}
}
// Check for duplicate thresholds
const thresholds = definition.achievements.map(a => a.threshold);
const uniqueThresholds = new Set(thresholds);
if (thresholds.length !== uniqueThresholds.size) {
errors.push('Achievements must have unique thresholds');
}
}
}
// Validate modeGroups if present // Validate modeGroups if present
if (definition.modeGroups) { if (definition.modeGroups) {
if (typeof definition.modeGroups !== 'object') { if (typeof definition.modeGroups !== 'object') {

View File

@@ -84,9 +84,65 @@ function loadAwardDefinitions() {
*/ */
export function clearAwardCache() { export function clearAwardCache() {
cachedAwardDefinitions = null; cachedAwardDefinitions = null;
cachedWAECountryList = null;
logger.info('Award cache cleared'); logger.info('Award cache cleared');
} }
/**
* Calculate achievement progress for an award
* @param {number} currentCount - Current confirmed count (entities or points)
* @param {Array} achievements - Array of achievement definitions
* @returns {Object|null} Achievement progress info or null if no achievements defined
*/
function calculateAchievementProgress(currentCount, achievements) {
if (!achievements || achievements.length === 0) {
return null;
}
// Sort achievements by threshold
const sorted = [...achievements].sort((a, b) => a.threshold - b.threshold);
// Find earned achievements, current level, and next level
const earned = [];
let currentLevel = null;
let nextLevel = null;
for (let i = 0; i < sorted.length; i++) {
const achievement = sorted[i];
if (currentCount >= achievement.threshold) {
earned.push(achievement);
currentLevel = achievement;
} else {
nextLevel = achievement;
break;
}
}
// Calculate progress toward next level
let progressPercent = 100;
let progressCurrent = currentCount;
let progressNeeded = 0;
if (nextLevel) {
const prevThreshold = currentLevel ? currentLevel.threshold : 0;
const range = nextLevel.threshold - prevThreshold;
const progressInLevel = currentCount - prevThreshold;
progressPercent = Math.round((progressInLevel / range) * 100);
progressNeeded = nextLevel.threshold - currentCount;
}
return {
earned,
currentLevel,
nextLevel,
progressPercent,
progressCurrent,
progressNeeded,
totalAchievements: sorted.length,
earnedCount: earned.length,
};
}
/** /**
* Get all available awards * Get all available awards
*/ */
@@ -101,6 +157,7 @@ export async function getAllAwards() {
category: def.category, category: def.category,
rules: def.rules, rules: def.rules,
modeGroups: def.modeGroups || null, modeGroups: def.modeGroups || null,
achievements: def.achievements || null,
})); }));
} }
@@ -125,6 +182,7 @@ export function getAwardById(awardId) {
category: award.category, category: award.category,
rules: award.rules, rules: award.rules,
modeGroups: award.modeGroups || null, modeGroups: award.modeGroups || null,
achievements: award.achievements || null,
}; };
} }
@@ -185,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
return calculatePointsAwardProgress(userId, award, { includeDetails }); return calculatePointsAwardProgress(userId, award, { includeDetails });
} }
// Handle WAE-based awards (Worked All Europe)
if (rules.type === 'wae') {
return calculateWAEAwardProgress(userId, award, { includeDetails });
}
// Get all QSOs for user // Get all QSOs for user
const allQSOs = await db const allQSOs = await db
.select() .select()
@@ -234,7 +297,7 @@ export async function calculateAwardProgress(userId, award, options = {}) {
} }
} }
return { const result = {
worked: workedEntities.size, worked: workedEntities.size,
confirmed: confirmedEntities.size, confirmed: confirmedEntities.size,
target: rules.target || 0, target: rules.target || 0,
@@ -242,6 +305,13 @@ export async function calculateAwardProgress(userId, award, options = {}) {
workedEntities: Array.from(workedEntities), workedEntities: Array.from(workedEntities),
confirmedEntities: Array.from(confirmedEntities), confirmedEntities: Array.from(confirmedEntities),
}; };
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateAchievementProgress(confirmedEntities.size, award.achievements);
}
return result;
} }
/** /**
@@ -362,6 +432,11 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
result.confirmed = result.entities.filter((e) => e.confirmed).length; result.confirmed = result.entities.filter((e) => e.confirmed).length;
} }
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateAchievementProgress(confirmedDOKs.size, award.achievements);
}
return result; return result;
} }
@@ -623,6 +698,12 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
result.stationDetails = stationDetails; result.stationDetails = stationDetails;
} }
// Add achievement progress if award has achievements defined
// For point-based awards, use totalPoints instead of confirmed count
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateAchievementProgress(totalPoints, award.achievements);
}
return result; return result;
} }
@@ -693,6 +774,503 @@ function matchesFilter(qso, filter) {
} }
} }
// ============================================================================
// WAE (Worked All Europe) Award Functions
// ============================================================================
// In-memory cache for WAE country list
let cachedWAECountryList = null;
/**
* Load WAE country list from JSON file
*/
function loadWAECountryList() {
if (cachedWAECountryList) {
return cachedWAECountryList;
}
try {
const filePath = join(process.cwd(), 'award-data', 'wae-country-list.json');
const content = readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
// Build lookup maps for efficient matching
const dxccMap = new Map();
const waeSpecificMap = new Map();
const deletedPrefixes = new Set();
// Index DXCC-based countries
if (data.dxccBased) {
for (const entry of data.dxccBased) {
dxccMap.set(entry.entityId, {
country: entry.country,
prefix: entry.prefix,
deleted: entry.deleted || false,
});
}
}
// Index WAE-specific countries with callsign patterns
if (data.waeSpecific) {
for (const entry of data.waeSpecific) {
waeSpecificMap.set(entry.prefix, {
country: entry.country,
prefix: entry.prefix,
callsigns: entry.callsigns || [],
parentDxcc: entry.parentDxcc,
});
}
}
// Index deleted countries
if (data.deletedCountries) {
for (const entry of data.deletedCountries) {
deletedPrefixes.add(entry.prefix);
}
}
cachedWAECountryList = {
dxccMap,
waeSpecificMap,
deletedPrefixes,
rawData: data,
};
logger.debug('WAE country list loaded', {
dxccCount: dxccMap.size,
waeSpecificCount: waeSpecificMap.size,
deletedCount: deletedPrefixes.size,
});
return cachedWAECountryList;
} catch (error) {
logger.error('Failed to load WAE country list', { error: error.message });
return { dxccMap: new Map(), waeSpecificMap: new Map(), deletedPrefixes: new Set(), rawData: null };
}
}
/**
* Match a callsign to WAE country
* Only matches if the country is explicitly in the WAE country list
* @param {string} callsign - The callsign to match
* @param {number} entityId - The DXCC entityId from QSO
* @returns {Object|null} WAE country info or null if not a WAE country
*/
function matchWAECountry(callsign, entityId) {
const waeList = loadWAECountryList();
if (!callsign) return null;
const normalizedCallsign = callsign.toUpperCase().trim();
// First check WAE-specific patterns (these override DXCC)
for (const [prefix, info] of waeList.waeSpecificMap) {
for (const pattern of info.callsigns) {
if (pattern.includes('*')) {
// Wildcard pattern matching
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
if (regex.test(normalizedCallsign)) {
return {
country: info.country,
prefix: info.prefix,
isDeleted: false,
isWAESpecific: true,
};
}
} else {
// Exact match
if (normalizedCallsign === pattern) {
return {
country: info.country,
prefix: info.prefix,
isDeleted: false,
isWAESpecific: true,
};
}
}
}
}
// Only match DXCC entities that are EXPLICITLY in the WAE country list
// Do NOT fall back to matching any DXCC entity - WAE has its own list
if (entityId && waeList.dxccMap.has(entityId)) {
const dxccInfo = waeList.dxccMap.get(entityId);
return {
country: dxccInfo.country,
prefix: dxccInfo.prefix,
isDeleted: dxccInfo.deleted,
isWAESpecific: false,
};
}
// Not a WAE country (includes all non-European entities like US, JA, etc.)
return null;
}
/**
* Get bandpoint value for a band
* @param {string} band - The band name
* @param {Array} doublePointBands - Bands that count double
* @returns {number} Point value for this band
*/
function getBandpointValue(band, doublePointBands = []) {
if (doublePointBands && doublePointBands.includes(band)) {
return 2;
}
return 1;
}
/**
* Sort bands by point value (descending) for max bands per country calculation
* @param {Array} bands - Array of band names
* @param {Array} doublePointBands - Bands that count double
* @returns {Array} Bands sorted by point value
*/
function sortBandsByPointValue(bands, doublePointBands = []) {
return bands.sort((a, b) => {
const pointsA = getBandpointValue(a, doublePointBands);
const pointsB = getBandpointValue(b, doublePointBands);
if (pointsA !== pointsB) {
return pointsB - pointsA; // Higher points first
}
// Tie-breaker: prefer lower frequency (longer wavelength) bands
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
const indexA = bandOrder.indexOf(a);
const indexB = bandOrder.indexOf(b);
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
return a.localeCompare(b);
});
}
/**
* Calculate progress for WAE awards
* WAE tracks dual metrics: unique countries AND bandpoints
* @param {number} userId - User ID
* @param {Object} award - Award definition
* @param {Object} options - Options
* @param {boolean} options.includeDetails - Include detailed entity breakdown
*/
async function calculateWAEAwardProgress(userId, award, options = {}) {
const { includeDetails = false } = options;
const { rules } = award;
const {
targetCountries = 40,
targetBandpoints = 100,
doublePointBands = ['160m', '80m'],
maxBandsPerCountry = 5,
excludeDeletedForTop = true,
} = rules;
logger.debug('Calculating WAE award progress', {
userId,
awardId: award.id,
targetCountries,
targetBandpoints,
doublePointBands,
maxBandsPerCountry,
excludeDeletedForTop,
});
// Get all QSOs for user
const allQSOs = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId));
logger.debug('Total QSOs for WAE calculation', { count: allQSOs.length });
// Track per-country data
// Map: country -> { confirmed: boolean, bands: Set, bandpoints: number, qsos: [] }
const countryData = new Map();
// Track all unique countries worked and confirmed
const workedCountries = new Set();
const confirmedCountries = new Set();
for (const qso of allQSOs) {
const waeCountry = matchWAECountry(qso.callsign, qso.entityId);
if (!waeCountry) {
// Not a WAE country, skip
continue;
}
const country = waeCountry.country;
// Track worked countries
workedCountries.add(country);
// Check for LoTW confirmation
if (qso.lotwQslRstatus === 'Y') {
confirmedCountries.add(country);
// Initialize country data if not exists
if (!countryData.has(country)) {
countryData.set(country, {
country,
prefix: waeCountry.prefix,
isDeleted: waeCountry.isDeleted,
isWAESpecific: waeCountry.isWAESpecific,
confirmed: true,
bands: new Set(),
bandpoints: 0,
qsos: [],
});
}
const data = countryData.get(country);
const band = qso.band || 'Unknown';
// Only count this band if we haven't seen it before for this country
if (!data.bands.has(band)) {
data.bands.add(band);
// Calculate bandpoints for this country
// Get all confirmed bands for this country, sort by point value, take top N
const allBands = Array.from(data.bands);
const sortedBands = sortBandsByPointValue(allBands, doublePointBands);
const bandsToCount = sortedBands.slice(0, maxBandsPerCountry);
// Recalculate total bandpoints
let newBandpoints = 0;
for (const b of bandsToCount) {
newBandpoints += getBandpointValue(b, doublePointBands);
}
data.bandpoints = newBandpoints;
}
// Add QSO to the qsos array for drill-down
data.qsos.push({
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
satName: qso.satName,
confirmed: true,
});
}
}
// Calculate total bandpoints across all countries
let totalBandpoints = 0;
for (const data of countryData.values()) {
totalBandpoints += data.bandpoints;
}
// For WAE TOP/Trophy, we may need to exclude deleted countries
let displayConfirmedCount = confirmedCountries.size;
let displayWorkedCount = workedCountries.size;
let displayTotalBandpoints = totalBandpoints;
// Check if this is for WAE TOP or Trophy (which exclude deleted countries)
if (excludeDeletedForTop) {
let confirmedWithoutDeleted = 0;
for (const country of confirmedCountries) {
const data = countryData.get(country);
if (data && !data.isDeleted) {
confirmedWithoutDeleted++;
}
}
// Store both counts
displayConfirmedCount = confirmedWithoutDeleted;
}
logger.debug('WAE award progress calculated', {
workedCountries: displayWorkedCount,
confirmedCountries: displayConfirmedCount,
totalBandpoints: displayTotalBandpoints,
targetCountries,
targetBandpoints,
});
// Build result
const result = {
worked: displayWorkedCount,
confirmed: displayConfirmedCount,
bandpoints: displayTotalBandpoints,
workedBandpoints: displayTotalBandpoints, // For consistency with worked/confirmed naming
targetCountries,
targetBandpoints,
percentage: targetCountries ? Math.round((displayConfirmedCount / targetCountries) * 100) : 0,
bandpointsPercentage: targetBandpoints ? Math.min(100, Math.round((displayTotalBandpoints / targetBandpoints) * 100)) : 0,
workedEntities: Array.from(workedCountries),
confirmedEntities: Array.from(confirmedCountries),
};
// Add details if requested
if (includeDetails) {
result.award = {
id: award.id,
name: award.name,
description: award.description,
caption: award.caption,
targetCountries,
targetBandpoints,
};
// Build entities array for detail view
// For WAE, we need to expand countries into (country, band, mode) slots
// to match the frontend's expected (entity, band, mode) structure
const expandedEntities = [];
for (const [countryName, data] of countryData) {
const { bands, bandpoints, qsos, prefix, isDeleted, isWAESpecific } = data;
// For each band, create a slot entry
for (const band of bands) {
// Get all modes used on this band for this country
const modesInBand = new Set();
for (const qso of qsos) {
if (qso.band === band) {
modesInBand.add(qso.mode || 'Unknown');
}
}
// Create a slot for each mode on this band
for (const mode of modesInBand) {
// Get QSOs for this specific (country, band, mode) combination
const slotQSOs = qsos.filter(q => q.band === band && q.mode === mode);
expandedEntities.push({
entity: countryName,
entityId: null,
entityName: countryName,
prefix,
isDeleted,
isWAESpecific,
band,
mode,
confirmed: true,
bandpoints: getBandpointValue(band, award.rules.doublePointBands),
worked: true,
qsos: slotQSOs,
});
}
}
// If no bands (shouldn't happen for confirmed countries, but handle edge case)
if (bands.length === 0 && confirmedCountries.has(countryName)) {
expandedEntities.push({
entity: countryName,
entityId: null,
entityName: countryName,
prefix,
isDeleted,
isWAESpecific,
band: 'Unknown',
mode: 'Unknown',
confirmed: true,
bandpoints: 0,
worked: true,
qsos: [],
});
}
}
result.entities = expandedEntities;
result.total = expandedEntities.length;
result.confirmed = expandedEntities.filter((e) => e.confirmed).length;
}
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateWAEAchievementProgress(
displayConfirmedCount,
displayTotalBandpoints,
award.achievements,
excludeDeletedForTop
);
}
return result;
}
/**
* Calculate achievement progress for WAE awards (dual thresholds)
* @param {number} confirmedCountries - Number of confirmed countries
* @param {number} totalBandpoints - Total bandpoints earned
* @param {Array} achievements - Array of achievement definitions
* @param {boolean} excludeDeletedForTop - Whether deleted countries are excluded
* @returns {Object} Achievement progress info
*/
function calculateWAEAchievementProgress(confirmedCountries, totalBandpoints, achievements, excludeDeletedForTop) {
if (!achievements || achievements.length === 0) {
return null;
}
// Sort achievements by thresholdCountries
const sorted = [...achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
// Find earned achievements, current level, and next level
const earned = [];
let currentLevel = null;
let nextLevel = null;
for (let i = 0; i < sorted.length; i++) {
const achievement = sorted[i];
// Check if achievement criteria are met
// For achievements with excludeDeleted flag, we need both thresholds met
// Otherwise, just check country and bandpoint thresholds
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
// Special handling for "requireAllCountries" (WAE Trophy)
let allCountriesMet = false;
if (achievement.requireAllCountries) {
const waeList = loadWAECountryList();
const totalCountries = waeList.dxccMap.size + waeList.waeSpecificMap.size;
allCountriesMet = confirmedCountries >= totalCountries;
}
const criteriaMet = achievement.requireAllCountries
? (allCountriesMet && bandpointsMet)
: (countriesMet && bandpointsMet);
if (criteriaMet) {
earned.push(achievement);
currentLevel = achievement;
} else {
nextLevel = achievement;
break;
}
}
// Calculate progress toward next level
let progressPercent = 100;
let progressCurrent = confirmedCountries;
let progressNeeded = 0;
let progressBandpointsCurrent = totalBandpoints;
let progressBandpointsNeeded = 0;
if (nextLevel) {
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
const range = nextLevel.thresholdCountries - prevThreshold;
const progressInLevel = confirmedCountries - prevThreshold;
progressPercent = Math.round((progressInLevel / range) * 100);
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
}
return {
earned,
currentLevel,
nextLevel,
progressPercent,
progressCurrent: confirmedCountries,
progressNeeded,
progressBandpointsCurrent: totalBandpoints,
progressBandpointsNeeded,
totalAchievements: sorted.length,
earnedCount: earned.length,
};
}
/** /**
* Get award progress with QSO details * Get award progress with QSO details
*/ */
@@ -776,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
return await calculatePointsAwardProgress(userId, award, { includeDetails: true }); return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
} }
// Handle WAE-based awards - use the dedicated function
if (rules.type === 'wae') {
return await calculateWAEAwardProgress(userId, award, { includeDetails: true });
}
// Get all QSOs for user // Get all QSOs for user
const allQSOs = await db const allQSOs = await db
.select() .select()
@@ -821,7 +1404,19 @@ export async function getAwardEntityBreakdown(userId, awardId) {
} }
displayName = String(rawValue || entity); displayName = String(rawValue || entity);
} else { } else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity); // Smart default based on entityType when displayField is not specified
const defaultDisplayField = {
'dxcc': 'entity',
'state': 'state',
'grid': 'grid',
'callsign': 'callsign'
}[rules.entityType] || 'entity';
let rawValue = qso[defaultDisplayField];
if (defaultDisplayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
} }
if (!slotMap.has(slotKey)) { if (!slotMap.has(slotKey)) {

View File

@@ -552,7 +552,7 @@ export async function getQSOStats(userId) {
}).from(qsos).where(eq(qsos.userId, userId)), }).from(qsos).where(eq(qsos.userId, userId)),
db.select({ db.select({
uniqueEntities: sql`CAST(COUNT(DISTINCT entity) AS INTEGER)`, uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`, uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)` uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
}).from(qsos).where(eq(qsos.userId, userId)) }).from(qsos).where(eq(qsos.userId, userId))

186
src/frontend/src/app.css Normal file
View File

@@ -0,0 +1,186 @@
/* Quickawards Theme System - CSS Variables */
/* Light Mode (default) */
:root, [data-theme="light"] {
/* Backgrounds */
--bg-body: #f5f5f5;
--bg-card: #ffffff;
--bg-navbar: #2c3e50;
--bg-footer: #2c3e50;
--bg-input: #ffffff;
--bg-hover: rgba(255, 255, 255, 0.1);
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
/* Text */
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverted: #ffffff;
--text-link: #4a90e2;
/* Primary colors */
--color-primary: #4a90e2;
--color-primary-hover: #357abd;
--color-primary-light: rgba(74, 144, 226, 0.1);
/* Secondary colors */
--color-secondary: #6c757d;
--color-secondary-hover: #5a6268;
/* Semantic colors */
--color-success: #065f46;
--color-success-bg: #d1fae5;
--color-success-light: #10b981;
--color-warning: #ffc107;
--color-warning-hover: #e0a800;
--color-warning-bg: #fff3cd;
--color-warning-text: #856404;
--color-error: #dc3545;
--color-error-hover: #c82333;
--color-error-bg: #fee2e2;
--color-error-text: #991b1b;
--color-info: #1e40af;
--color-info-bg: #dbeafe;
--color-info-text: #1e40af;
/* Badge/status colors */
--badge-pending-bg: #fef3c7;
--badge-pending-text: #92400e;
--badge-running-bg: #dbeafe;
--badge-running-text: #1e40af;
--badge-completed-bg: #d1fae5;
--badge-completed-text: #065f46;
--badge-failed-bg: #fee2e2;
--badge-failed-text: #991b1b;
--badge-cancelled-bg: #f3e8ff;
--badge-cancelled-text: #6b21a8;
--badge-purple-bg: #8b5cf6;
--badge-purple-text: #ffffff;
/* Borders */
--border-color: #e0e0e0;
--border-color-light: #e9ecef;
--border-radius: 4px;
--border-radius-lg: 8px;
--border-radius-pill: 12px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.12);
/* Focus */
--focus-ring: 0 0 0 2px rgba(74, 144, 226, 0.2);
/* Logout button */
--color-logout: #ff6b6b;
--color-logout-hover: #ff5252;
--color-logout-bg: rgba(255, 107, 107, 0.1);
/* Admin link */
--color-admin-bg: #ffc107;
--color-admin-hover: #e0a800;
--color-admin-text: #000000;
/* Impersonation banner */
--impersonation-bg: #fff3cd;
--impersonation-border: #ffc107;
--impersonation-text: #856404;
/* Gradient colors */
--gradient-primary: linear-gradient(90deg, #4a90e2 0%, #357abd 100%);
--gradient-purple: linear-gradient(90deg, #8b5cf6, #a78bfa);
--gradient-scheduled: linear-gradient(to right, #f8f7ff, white);
}
/* Dark Mode */
[data-theme="dark"] {
/* Backgrounds */
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--bg-navbar: #1f2937;
--bg-footer: #1f2937;
--bg-input: #2d2d2d;
--bg-hover: rgba(255, 255, 255, 0.1);
--bg-secondary: #252525;
--bg-tertiary: #333333;
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--text-inverted: #ffffff;
--text-link: #5ba3f5;
/* Primary colors */
--color-primary: #5ba3f5;
--color-primary-hover: #4a8ae4;
--color-primary-light: rgba(91, 163, 245, 0.15);
/* Secondary colors */
--color-secondary: #6b7280;
--color-secondary-hover: #4b5563;
/* Semantic colors */
--color-success: #10b981;
--color-success-bg: #064e3b;
--color-success-light: #10b981;
--color-warning: #fbbf24;
--color-warning-hover: #f59e0b;
--color-warning-bg: #451a03;
--color-warning-text: #fef3c7;
--color-error: #f87171;
--color-error-hover: #ef4444;
--color-error-bg: #7f1d1d;
--color-error-text: #fecaca;
--color-info: #3b82f6;
--color-info-bg: #1e3a8a;
--color-info-text: #93c5fd;
/* Badge/status colors */
--badge-pending-bg: #451a03;
--badge-pending-text: #fef3c7;
--badge-running-bg: #1e3a8a;
--badge-running-text: #93c5fd;
--badge-completed-bg: #064e3b;
--badge-completed-text: #6ee7b7;
--badge-failed-bg: #7f1d1d;
--badge-failed-text: #fecaca;
--badge-cancelled-bg: #3b0a4d;
--badge-cancelled-text: #d8b4fe;
--badge-purple-bg: #7c3aed;
--badge-purple-text: #ffffff;
/* Borders */
--border-color: #404040;
--border-color-light: #4a4a4a;
/* Focus */
--focus-ring: 0 0 0 2px rgba(91, 163, 245, 0.2);
/* Logout button */
--color-logout: #f87171;
--color-logout-hover: #ef4444;
--color-logout-bg: rgba(248, 113, 113, 0.15);
/* Admin link */
--color-admin-bg: #f59e0b;
--color-admin-hover: #d97706;
--color-admin-text: #000000;
/* Impersonation banner */
--impersonation-bg: #451a03;
--impersonation-border: #f59e0b;
--impersonation-text: #fef3c7;
/* Gradient colors */
--gradient-primary: linear-gradient(90deg, #5ba3f5 0%, #4a8ae4 100%);
--gradient-purple: linear-gradient(90deg, #7c3aed, #8b5cf6);
--gradient-scheduled: linear-gradient(to right, #2d1f3d, #2d2d2d);
}

View File

@@ -4,6 +4,14 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<script>
// Prevent flash of unstyled content (FOUC)
(function() {
const theme = localStorage.getItem('theme') || 'light';
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
})();
</script>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>

View File

@@ -95,9 +95,9 @@ export const adminAPI = {
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`), getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, { updateUserRole: (userId, role) => apiRequest(`/admin/users/${userId}/role`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ isAdmin }), body: JSON.stringify({ role }),
}), }),
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, { deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {

View File

@@ -11,10 +11,10 @@
<style> <style>
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -22,12 +22,12 @@
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.back-button.secondary { .back-button.secondary {
background-color: transparent; background-color: transparent;
color: #4a90e2; color: var(--color-primary);
padding: 0; padding: 0;
} }

View File

@@ -16,21 +16,21 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #d32f2f; color: var(--color-error);
} }
.btn { .btn {
display: inline-block; display: inline-block;
margin-top: 1rem; margin-top: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
} }
.btn:hover { .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
</style> </style>

View File

@@ -11,6 +11,6 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--text-secondary);
} }
</style> </style>

View File

@@ -0,0 +1,152 @@
<script>
import { theme } from '$lib/stores/theme.js';
import { browser } from '$app/environment';
let isOpen = false;
const themes = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '💻' },
];
function selectTheme(value) {
theme.setTheme(value);
isOpen = false;
}
function toggle() {
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (!event.target.closest('.theme-switcher')) {
isOpen = false;
}
}
if (browser) {
document.addEventListener('click', handleClickOutside);
}
</script>
<svelte:head>
<style>
/* Ensure dropdown is above other content */
.theme-dropdown {
z-index: 1000;
}
</style>
</svelte:head>
<div class="theme-switcher">
<button
class="theme-button"
on:click={toggle}
aria-label="Change theme"
aria-expanded={isOpen}
>
<span class="current-icon">
{#if $theme === 'light'}
☀️
{:else if $theme === 'dark'}
🌙
{:else}
💻
{/if}
</span>
</button>
{#if isOpen}
<div class="theme-dropdown">
{#each themes as t}
<button
class="theme-option"
class:active={$theme === t.value}
on:click={() => selectTheme(t.value)}
>
<span class="theme-icon">{t.icon}</span>
<span class="theme-label">{t.label}</span>
{#if $theme === t.value}
<span class="checkmark"></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.theme-switcher {
position: relative;
display: inline-block;
}
.theme-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius);
transition: background-color 0.2s;
color: var(--text-inverted);
font-size: 1.25rem;
}
.theme-button:hover {
background-color: var(--bg-hover);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
min-width: 140px;
overflow: hidden;
}
.theme-option {
width: 100%;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
color: var(--text-primary);
transition: background-color 0.2s;
}
.theme-option:hover {
background-color: var(--bg-secondary);
}
.theme-option.active {
background-color: var(--bg-secondary);
font-weight: 600;
}
.theme-icon {
font-size: 1.1rem;
}
.theme-label {
flex: 1;
}
.checkmark {
color: var(--color-primary);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,56 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Theme store
* Manages theme state (light, dark, system) with localStorage persistence
*/
function createThemeStore() {
// Initialize state from localStorage
const initialState = browser ? localStorage.getItem('theme') || 'light' : 'light';
const { subscribe, set, update } = writable(initialState);
// Listen for system preference changes
let mediaQuery;
if (browser) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = () => {
// Trigger update to recompute isDark
update((n) => n);
};
mediaQuery.addEventListener('change', handleMediaChange);
}
// Derived store for whether dark mode should be active
const isDark = derived(initialState, ($theme) => {
if (!browser) return false;
if ($theme === 'dark') return true;
if ($theme === 'light') return false;
// system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
return {
subscribe,
isDark,
setTheme: (theme) => {
if (browser) {
localStorage.setItem('theme', theme);
// Apply data-theme attribute to document
const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
}
set(theme);
},
// Initialize theme on client-side
init: () => {
if (!browser) return;
const theme = localStorage.getItem('theme') || 'light';
const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
},
};
}
export const theme = createThemeStore();

View File

@@ -1,11 +1,20 @@
<script> <script>
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { theme } from '$lib/stores/theme.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { adminAPI, authAPI } from '$lib/api.js'; import { adminAPI, authAPI } from '$lib/api.js';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import '../app.css';
let stoppingImpersonation = false; let stoppingImpersonation = false;
// Initialize theme on mount
onMount(() => {
theme.init();
});
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
// Use hard redirect to ensure proper navigation after logout // Use hard redirect to ensure proper navigation after logout
@@ -62,6 +71,7 @@
{#if $auth.user?.isAdmin} {#if $auth.user?.isAdmin}
<a href="/admin" class="nav-link admin-link">Admin</a> <a href="/admin" class="nav-link admin-link">Admin</a>
{/if} {/if}
<ThemeSwitcher />
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button> <button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
</div> </div>
</div> </div>
@@ -111,7 +121,8 @@
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5; background-color: var(--bg-body);
color: var(--text-primary);
} }
.app { .app {
@@ -121,12 +132,12 @@
} }
.navbar { .navbar {
background-color: #2c3e50; background-color: var(--bg-navbar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
.nav-container { .nav-container {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 0 1rem; padding: 0 1rem;
display: flex; display: flex;
@@ -136,7 +147,7 @@
} }
.nav-brand .callsign { .nav-brand .callsign {
color: white; color: var(--text-inverted);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
@@ -148,11 +159,12 @@
} }
.nav-link { .nav-link {
color: rgba(255, 255, 255, 0.8); color: var(--text-inverted);
opacity: 0.8;
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 4px; border-radius: var(--border-radius);
transition: all 0.2s; transition: all 0.2s;
background: none; background: none;
border: none; border: none;
@@ -161,40 +173,41 @@
} }
.nav-link:hover { .nav-link:hover {
color: white; opacity: 1;
background-color: rgba(255, 255, 255, 0.1); background-color: var(--bg-hover);
} }
.logout-btn { .logout-btn {
color: #ff6b6b; color: var(--color-logout);
opacity: 1;
} }
.logout-btn:hover { .logout-btn:hover {
color: #ff5252; background-color: var(--color-logout-bg);
background-color: rgba(255, 107, 107, 0.1);
} }
.admin-link { .admin-link {
background-color: #ffc107; background-color: var(--color-admin-bg);
color: #000; color: var(--color-admin-text);
font-weight: 600; font-weight: 600;
} }
.admin-link:hover { .admin-link:hover {
background-color: #e0a800; background-color: var(--color-admin-hover);
} }
main { main {
flex: 1; flex: 1;
padding: 2rem 1rem; padding: 2rem 1rem;
max-width: 1200px; max-width: 1600px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
} }
footer { footer {
background-color: #2c3e50; background-color: var(--bg-footer);
color: rgba(255, 255, 255, 0.7); color: var(--text-inverted);
opacity: 0.7;
text-align: center; text-align: center;
padding: 1.5rem; padding: 1.5rem;
margin-top: auto; margin-top: auto;
@@ -207,13 +220,13 @@
/* Impersonation Banner */ /* Impersonation Banner */
.impersonation-banner { .impersonation-banner {
background-color: #fff3cd; background-color: var(--impersonation-bg);
border: 2px solid #ffc107; border: 2px solid var(--impersonation-border);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.impersonation-content { .impersonation-content {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -228,15 +241,15 @@
.impersonation-text { .impersonation-text {
flex: 1; flex: 1;
font-size: 0.95rem; font-size: 0.95rem;
color: #856404; color: var(--impersonation-text);
} }
.stop-impersonation-btn { .stop-impersonation-btn {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: var(--impersonation-text);
border: none; border: none;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
@@ -244,7 +257,7 @@
} }
.stop-impersonation-btn:hover:not(:disabled) { .stop-impersonation-btn:hover:not(:disabled) {
background-color: #e0a800; background-color: var(--color-warning-hover);
} }
.stop-impersonation-btn:disabled { .stop-impersonation-btn:disabled {

View File

@@ -445,13 +445,13 @@
.welcome h1 { .welcome h1 {
font-size: 2.5rem; font-size: 2.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.subtitle { .subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: #666; color: var(--text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -464,12 +464,12 @@
.dashboard h1 { .dashboard h1 {
font-size: 2rem; font-size: 2rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.welcome-section p { .welcome-section p {
color: #666; color: var(--text-secondary);
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -482,21 +482,21 @@
} }
.action-card { .action-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.action-card h3 { .action-card h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #333; color: var(--text-primary);
} }
.action-card p { .action-card p {
color: #666; color: var(--text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -504,7 +504,7 @@
display: inline-block; display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
@@ -513,25 +513,25 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.action-card .btn { .action-card .btn {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -539,25 +539,25 @@
} }
.action-card .btn:hover { .action-card .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.info-box { .info-box {
background: #f8f9fa; background: var(--bg-secondary);
border-left: 4px solid #4a90e2; border-left: 4px solid var(--color-primary);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-box h3 { .info-box h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
} }
.info-box ol { .info-box ol {
margin: 0; margin: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
color: #666; color: var(--text-secondary);
line-height: 1.8; line-height: 1.8;
} }
@@ -568,18 +568,18 @@
.section-title { .section-title {
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.loading-state, .loading-state,
.empty-state { .empty-state {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: #666; color: var(--text-secondary);
} }
.empty-actions { .empty-actions {
@@ -597,25 +597,25 @@
} }
.job-card { .job-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
} }
.job-card:hover { .job-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); box-shadow: var(--shadow-md);
} }
.job-card.failed { .job-card.failed {
border-left: 4px solid #dc3545; border-left: 4px solid var(--color-error);
} }
.job-card-scheduled { .job-card-scheduled {
border-left: 4px solid #8b5cf6; border-left: 4px solid var(--badge-purple-bg);
background: linear-gradient(to right, #f8f7ff, white); background: var(--gradient-scheduled);
} }
.job-header { .job-header {
@@ -637,62 +637,62 @@
.job-name { .job-name {
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 1.1rem; font-size: 1.1rem;
} }
.job-id { .job-id {
font-size: 0.85rem; font-size: 0.85rem;
color: #999; color: var(--text-muted);
font-family: monospace; font-family: monospace;
} }
.status-badge { .status-badge {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--border-radius-pill);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
text-transform: capitalize; text-transform: capitalize;
} }
.bg-yellow-100 { .bg-yellow-100 {
background-color: #fef3c7; background-color: var(--badge-pending-bg);
} }
.bg-blue-100 { .bg-blue-100 {
background-color: #dbeafe; background-color: var(--badge-running-bg);
} }
.bg-green-100 { .bg-green-100 {
background-color: #d1fae5; background-color: var(--badge-completed-bg);
} }
.bg-red-100 { .bg-red-100 {
background-color: #fee2e2; background-color: var(--badge-failed-bg);
} }
.text-yellow-800 { .text-yellow-800 {
color: #92400e; color: var(--badge-pending-text);
} }
.text-blue-800 { .text-blue-800 {
color: #1e40af; color: var(--badge-running-text);
} }
.text-green-800 { .text-green-800 {
color: #065f46; color: var(--badge-completed-text);
} }
.text-red-800 { .text-red-800 {
color: #991b1b; color: var(--badge-failed-text);
} }
.bg-purple-100 { .bg-purple-100 {
background-color: #f3e8ff; background-color: var(--badge-cancelled-bg);
} }
.text-purple-800 { .text-purple-800 {
color: #6b21a8; color: var(--badge-cancelled-text);
} }
.job-badge { .job-badge {
@@ -705,15 +705,15 @@
} }
.scheduled-badge { .scheduled-badge {
background-color: #8b5cf6; background-color: var(--badge-purple-bg);
color: white; color: var(--badge-purple-text);
} }
.job-meta { .job-meta {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
color: #666; color: var(--text-secondary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -724,14 +724,14 @@
.job-time, .job-time,
.job-duration { .job-duration {
color: #999; color: var(--text-muted);
} }
.job-error { .job-error {
background: #fee2e2; background: var(--color-error-bg);
color: #991b1b; color: var(--color-error-text);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.95rem; font-size: 0.95rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -745,29 +745,29 @@
.stat-item { .stat-item {
font-size: 0.9rem; font-size: 0.9rem;
color: #666; color: var(--text-secondary);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: #f8f9fa; background: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
} }
.stat-item strong { .stat-item strong {
color: #333; color: var(--text-primary);
} }
.stat-added { .stat-added {
color: #065f46; color: var(--color-success);
background: #d1fae5; background: var(--color-success-bg);
} }
.stat-updated { .stat-updated {
color: #1e40af; color: var(--color-info);
background: #dbeafe; background: var(--color-info-bg);
} }
.stat-skipped { .stat-skipped {
color: #92400e; color: var(--badge-pending-text);
background: #fef3c7; background: var(--badge-pending-bg);
} }
.job-progress { .job-progress {
@@ -775,7 +775,7 @@
} }
.progress-text { .progress-text {
color: #1e40af; color: var(--color-info);
font-size: 0.9rem; font-size: 0.9rem;
font-style: italic; font-style: italic;
} }
@@ -789,17 +789,17 @@
.btn-cancel { .btn-cancel {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
font-size: 0.85rem; font-size: 0.85rem;
border: 1px solid #dc3545; border: 1px solid var(--color-error);
background: white; background: var(--bg-card);
color: #dc3545; color: var(--color-error);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-weight: 500; font-weight: 500;
} }
.btn-cancel:hover:not(:disabled) { .btn-cancel:hover:not(:disabled) {
background: #dc3545; background: var(--color-error);
color: white; color: white;
} }
@@ -820,7 +820,7 @@
.countdown-bar { .countdown-bar {
height: 6px; height: 6px;
background: #e5e7eb; background: var(--border-color-light);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -828,7 +828,7 @@
.countdown-progress { .countdown-progress {
height: 100%; height: 100%;
background: linear-gradient(90deg, #8b5cf6, #a78bfa); background: var(--gradient-purple);
border-radius: 3px; border-radius: 3px;
width: 100%; width: 100%;
animation: pulse-countdown 2s ease-in-out infinite; animation: pulse-countdown 2s ease-in-out infinite;
@@ -846,7 +846,7 @@
.countdown-text { .countdown-text {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
color: #8b5cf6; color: var(--badge-purple-bg);
text-align: center; text-align: center;
font-weight: 500; font-weight: 500;
} }

View File

@@ -141,19 +141,19 @@
} }
} }
async function handleRoleChange(userId, newIsAdmin) { async function handleRoleChange(userId, newRole) {
try { try {
loading = true; loading = true;
const data = await adminAPI.updateUserRole(userId, newIsAdmin); const data = await adminAPI.updateUserRole(userId, newRole);
if (data.success) { if (data.success) {
alert(data.message); alert(data.message);
await loadUsers(); await loadUsers();
} else { } else {
alert('Failed to update user admin status: ' + (data.error || 'Unknown error')); alert('Failed to update user role: ' + (data.error || 'Unknown error'));
} }
} catch (err) { } catch (err) {
alert('Failed to update user admin status: ' + err.message); alert('Failed to update user role: ' + err.message);
} finally { } finally {
loading = false; loading = false;
showRoleChangeModal = false; showRoleChangeModal = false;
@@ -197,7 +197,8 @@
user.callsign.toLowerCase().includes(userSearch.toLowerCase()); user.callsign.toLowerCase().includes(userSearch.toLowerCase());
const matchesFilter = userFilter === 'all' || const matchesFilter = userFilter === 'all' ||
(userFilter === 'admin' && user.isAdmin) || (userFilter === 'super-admin' && user.isSuperAdmin) ||
(userFilter === 'admin' && user.isAdmin && !user.isSuperAdmin) ||
(userFilter === 'user' && !user.isAdmin); (userFilter === 'user' && !user.isAdmin);
return matchesSearch && matchesFilter; return matchesSearch && matchesFilter;
@@ -317,6 +318,7 @@
/> />
<select class="filter-select" bind:value={userFilter}> <select class="filter-select" bind:value={userFilter}>
<option value="all">All Users</option> <option value="all">All Users</option>
<option value="super-admin">Super Admins Only</option>
<option value="admin">Admins Only</option> <option value="admin">Admins Only</option>
<option value="user">Regular Users Only</option> <option value="user">Regular Users Only</option>
</select> </select>
@@ -336,6 +338,7 @@
<th>DCL Conf.</th> <th>DCL Conf.</th>
<th>Total Conf.</th> <th>Total Conf.</th>
<th>Last Sync</th> <th>Last Sync</th>
<th>Last Seen</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -346,8 +349,8 @@
<td>{user.email}</td> <td>{user.email}</td>
<td>{user.callsign}</td> <td>{user.callsign}</td>
<td> <td>
<span class="role-badge {user.isAdmin ? 'admin' : 'user'}"> <span class="role-badge {user.isSuperAdmin ? 'super-admin' : (user.isAdmin ? 'admin' : 'user')}">
{user.isAdmin ? 'Admin' : 'User'} {user.isSuperAdmin ? 'Super Admin' : (user.isAdmin ? 'Admin' : 'User')}
</span> </span>
</td> </td>
<td>{user.qsoCount || 0}</td> <td>{user.qsoCount || 0}</td>
@@ -355,11 +358,12 @@
<td>{user.dclConfirmed || 0}</td> <td>{user.dclConfirmed || 0}</td>
<td>{user.totalConfirmed || 0}</td> <td>{user.totalConfirmed || 0}</td>
<td>{formatDate(user.lastSync)}</td> <td>{formatDate(user.lastSync)}</td>
<td>{formatDate(user.lastSeen)}</td>
<td class="actions-cell"> <td class="actions-cell">
<button <button
class="action-button impersonate-btn" class="action-button impersonate-btn"
on:click={() => openImpersonationModal(user)} on:click={() => openImpersonationModal(user)}
disabled={user.isAdmin} disabled={user.isAdmin && !$auth.user.isSuperAdmin}
> >
Impersonate Impersonate
</button> </button>
@@ -493,25 +497,34 @@
<div class="modal-content" on:click|stopPropagation> <div class="modal-content" on:click|stopPropagation>
<h2>Change User Role</h2> <h2>Change User Role</h2>
<p>User: <strong>{selectedUser.email}</strong></p> <p>User: <strong>{selectedUser.email}</strong></p>
<p>Current Role: <strong>{selectedUser.isAdmin ? 'Admin' : 'User'}</strong></p> <p>Current Role: <strong>{selectedUser.isSuperAdmin ? 'Super Admin' : (selectedUser.isAdmin ? 'Admin' : 'User')}</strong></p>
<p>New Role:</p> <p>New Role:</p>
<div class="role-options"> <div class="role-options">
<label> <label>
<input type="radio" name="role" value="user" checked={!selectedUser.isAdmin} /> <input type="radio" name="role" value="user" checked={!selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
Regular User Regular User
</label> </label>
<label> <label>
<input type="radio" name="role" value="admin" checked={selectedUser.isAdmin} /> <input type="radio" name="role" value="admin" checked={selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
Admin Admin
</label> </label>
{#if $auth.user.isSuperAdmin}
<label>
<input type="radio" name="role" value="super-admin" checked={selectedUser.isSuperAdmin} />
Super Admin
</label>
{/if}
</div> </div>
{#if !$auth.user.isSuperAdmin && (selectedUser.isSuperAdmin || (selectedUser.isAdmin && selectedUser.email !== selectedUser.email))}
<p class="warning">Note: Only super-admins can promote or demote super-admins.</p>
{/if}
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}> <button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
Cancel Cancel
</button> </button>
<button <button
class="modal-button confirm" class="modal-button confirm"
on:click={() => handleRoleChange(selectedUser.id, !selectedUser.isAdmin)} on:click={() => handleRoleChange(selectedUser.id, document.querySelector('input[name="role"]:checked')?.value || 'user')}
> >
Change Role Change Role
</button> </button>
@@ -548,7 +561,7 @@
<style> <style>
.admin-dashboard { .admin-dashboard {
padding: 2rem; padding: 2rem;
max-width: 1400px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -556,19 +569,20 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-secondary);
} }
.error { .error {
background-color: #fee; background-color: var(--color-error-bg);
border: 1px solid #fcc; border: 1px solid var(--color-error);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #c00; color: var(--color-error-text);
} }
h1 { h1 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #333; color: var(--text-primary);
} }
/* Tabs */ /* Tabs */
@@ -576,7 +590,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
border-bottom: 2px solid #ddd; border-bottom: 2px solid var(--border-color);
} }
.tab { .tab {
@@ -586,30 +600,30 @@
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #666; color: var(--text-secondary);
position: relative; position: relative;
top: 2px; top: 2px;
} }
.tab:hover { .tab:hover {
color: #333; color: var(--text-primary);
} }
.tab.active { .tab.active {
color: #007bff; color: var(--color-primary);
border-bottom: 2px solid #007bff; border-bottom: 2px solid var(--color-primary);
} }
.tab-content { .tab-content {
background: white; background: var(--bg-card);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
h2 { h2 {
margin-bottom: 1rem; margin-bottom: 1rem;
color: #333; color: var(--text-primary);
} }
/* Stats Grid */ /* Stats Grid */
@@ -624,8 +638,8 @@
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.stat-card h3 { .stat-card h3 {
@@ -668,9 +682,11 @@
.search-input, .search-input,
.filter-select { .filter-select {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
@@ -685,8 +701,8 @@
.users-table { .users-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@@ -694,23 +710,23 @@
.users-table td { .users-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.users-table th { .users-table th {
background-color: #f5f5f5; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.users-table tr:hover { .users-table tr:hover {
background-color: #f9f9f9; background-color: var(--bg-secondary);
} }
.admin-row { .admin-row {
background-color: #fff9e6 !important; background-color: var(--color-warning-bg) !important;
} }
.actions-cell { .actions-cell {
@@ -721,7 +737,7 @@
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
margin-right: 0.3rem; margin-right: 0.3rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
transition: all 0.2s; transition: all 0.2s;
@@ -742,21 +758,21 @@
} }
.role-btn { .role-btn {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: #000;
} }
.role-btn:hover:not(:disabled) { .role-btn:hover:not(:disabled) {
background-color: #e0a800; background-color: var(--color-warning-hover);
} }
.delete-btn { .delete-btn {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.delete-btn:hover:not(:disabled) { .delete-btn:hover:not(:disabled) {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.role-badge { .role-badge {
@@ -771,14 +787,19 @@
color: white; color: white;
} }
.role-badge.super-admin {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.role-badge.user { .role-badge.user {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.users-count { .users-count {
margin-top: 1rem; margin-top: 1rem;
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
} }
@@ -790,8 +811,8 @@
.actions-table { .actions-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@@ -799,15 +820,15 @@
.actions-table td { .actions-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.actions-table th { .actions-table th {
background-color: #f5f5f5; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.action-type { .action-type {
@@ -818,12 +839,12 @@
} }
.action-type.impersonate_start { .action-type.impersonate_start {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: #000;
} }
.action-type.impersonate_stop { .action-type.impersonate_stop {
background-color: #28a745; background-color: var(--color-success-light);
color: white; color: white;
} }
@@ -833,14 +854,14 @@
} }
.action-type.user_delete { .action-type.user_delete {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.details-json { .details-json {
background-color: #f5f5f5; background-color: var(--bg-secondary);
padding: 0.5rem; padding: 0.5rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.8rem; font-size: 0.8rem;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
@@ -849,7 +870,7 @@
.no-actions { .no-actions {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
color: #666; color: var(--text-secondary);
} }
/* Modal */ /* Modal */
@@ -867,30 +888,30 @@
} }
.modal-content { .modal-content {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.modal-content h2 { .modal-content h2 {
margin-top: 0; margin-top: 0;
color: #333; color: var(--text-primary);
} }
.modal-content p { .modal-content p {
color: #666; color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
} }
.modal-content .warning { .modal-content .warning {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
border-left: 4px solid #ffc107; border-left: 4px solid var(--color-warning);
padding: 1rem; padding: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #856404; color: var(--badge-pending-text);
} }
.modal-actions { .modal-actions {
@@ -903,36 +924,36 @@
.modal-button { .modal-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
} }
.modal-button.cancel { .modal-button.cancel {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.modal-button.cancel:hover { .modal-button.cancel:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.modal-button.confirm { .modal-button.confirm {
background-color: #007bff; background-color: var(--color-primary);
color: white; color: white;
} }
.modal-button.confirm:hover { .modal-button.confirm:hover {
background-color: #0056b3; background-color: var(--color-primary-hover);
} }
.modal-button.delete-confirm { .modal-button.delete-confirm {
background-color: #dc3545; background-color: var(--color-error);
} }
.modal-button.delete-confirm:hover { .modal-button.delete-confirm:hover {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.role-options { .role-options {
@@ -950,7 +971,7 @@
} }
.help-text { .help-text {
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -960,16 +981,16 @@
} }
.awards-info { .awards-info {
background-color: #f9f9f9; background-color: var(--bg-secondary);
border-left: 4px solid #667eea; border-left: 4px solid #667eea;
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.awards-info h3 { .awards-info h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
color: #333; color: var(--text-primary);
} }
.awards-info p { .awards-info p {
@@ -986,7 +1007,7 @@
} }
.awards-info code { .awards-info code {
background-color: #e0e0e0; background-color: var(--border-color);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 3px; border-radius: 3px;
font-family: monospace; font-family: monospace;

View File

@@ -60,7 +60,8 @@
'dok': 'DOK', 'dok': 'DOK',
'points': 'Points', 'points': 'Points',
'filtered': 'Filtered', 'filtered': 'Filtered',
'counter': 'Counter' 'counter': 'Counter',
'wae': 'WAE'
}; };
return names[ruleType] || ruleType; return names[ruleType] || ruleType;
} }
@@ -168,7 +169,7 @@
<style> <style>
.awards-admin { .awards-admin {
padding: 2rem; padding: 2rem;
max-width: 1400px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -176,15 +177,15 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.2rem; font-size: 1.2rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
background-color: #fee; background-color: var(--color-error-bg);
border: 1px solid #fcc; border: 1px solid var(--color-error);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #c00; color: var(--color-error);
margin: 2rem; margin: 2rem;
} }
@@ -199,7 +200,7 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.filters { .filters {
@@ -212,9 +213,11 @@
.search-input, .search-input,
.category-filter { .category-filter {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
@@ -229,7 +232,7 @@
.btn { .btn {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
@@ -238,19 +241,19 @@
} }
.btn-primary { .btn-primary {
background-color: #667eea; background-color: var(--color-primary);
color: white; color: var(--text-inverted);
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #5568d3; background-color: var(--color-primary-hover);
} }
.awards-table-container { .awards-table-container {
overflow-x: auto; overflow-x: auto;
background: white; background: var(--bg-card);
border-radius: 8px; border-radius: var(--border-radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.awards-table { .awards-table {
@@ -262,24 +265,25 @@
.awards-table td { .awards-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
} }
.awards-table th { .awards-table th {
background-color: #f5f5f5; background-color: var(--bg-hover);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.awards-table tr:hover { .awards-table tr:hover {
background-color: #f9f9f9; background-color: var(--bg-secondary);
} }
.id-cell { .id-cell {
font-family: monospace; font-family: monospace;
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -289,7 +293,7 @@
} }
.name-cell small { .name-cell small {
color: #888; color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
@@ -317,17 +321,17 @@
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
margin-right: 0.3rem; margin-right: 0.3rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-inverted);
} }
.edit-btn { .edit-btn {
background-color: #3498db; background-color: #3498db;
color: white;
} }
.edit-btn:hover { .edit-btn:hover {
@@ -336,7 +340,6 @@
.view-btn { .view-btn {
background-color: #27ae60; background-color: #27ae60;
color: white;
} }
.view-btn:hover { .view-btn:hover {
@@ -345,7 +348,6 @@
.delete-btn { .delete-btn {
background-color: #e74c3c; background-color: #e74c3c;
color: white;
} }
.delete-btn:hover { .delete-btn:hover {
@@ -354,7 +356,7 @@
.count { .count {
margin-top: 1rem; margin-top: 1rem;
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
} }

View File

@@ -16,7 +16,7 @@
// UI state // UI state
let showTestModal = false; let showTestModal = false;
let activeTab = 'basic'; // basic, modeGroups, rules let activeTab = 'basic'; // basic, modeGroups, achievements, rules
// Form data // Form data
let formData = { let formData = {
@@ -26,11 +26,16 @@
caption: '', caption: '',
category: '', category: '',
modeGroups: {}, modeGroups: {},
achievements: [],
rules: { rules: {
type: 'entity', type: 'entity',
} }
}; };
// Achievements editor state
let newAchievementName = '';
let newAchievementThreshold = 100;
// Update stations store when formData changes // Update stations store when formData changes
$: if (formData.rules?.stations) { $: if (formData.rules?.stations) {
stationsStore.set(formData.rules.stations.map(s => ({...s}))); stationsStore.set(formData.rules.stations.map(s => ({...s})));
@@ -66,6 +71,7 @@
caption: data.award.caption || '', caption: data.award.caption || '',
category: data.award.category || '', category: data.award.category || '',
modeGroups: data.award.modeGroups || {}, modeGroups: data.award.modeGroups || {},
achievements: data.award.achievements || [],
rules: data.award.rules || { type: 'entity' }, rules: data.award.rules || { type: 'entity' },
}; };
awardId = id; awardId = id;
@@ -115,11 +121,17 @@
case 'counter': case 'counter':
validateCounterRule(errors, warnings); validateCounterRule(errors, warnings);
break; break;
case 'wae':
validateWAERule(errors, warnings);
break;
} }
// Mode groups validation // Mode groups validation
validateModeGroups(errors, warnings); validateModeGroups(errors, warnings);
// Achievements validation
validateAchievements(errors, warnings);
// Cross-field validation // Cross-field validation
performCrossFieldValidation(errors, warnings); performCrossFieldValidation(errors, warnings);
@@ -227,6 +239,29 @@
} }
} }
function validateWAERule(errors, warnings) {
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
errors.push('WAE rule requires targetCountries (positive number)');
}
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
errors.push('WAE rule requires targetBandpoints (positive number)');
}
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
}
// Check that double-point bands are valid
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
if (invalid.length > 0) {
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
}
}
}
function validateModeGroups(errors, warnings) { function validateModeGroups(errors, warnings) {
if (!formData.modeGroups) return; if (!formData.modeGroups) return;
@@ -270,6 +305,46 @@
} }
} }
function validateAchievements(errors, warnings) {
if (!formData.achievements || formData.achievements.length === 0) {
return; // Achievements are optional
}
// Check for duplicate thresholds
const thresholds = formData.achievements.map(a => a.threshold);
const uniqueThresholds = new Set(thresholds);
if (thresholds.length !== uniqueThresholds.size) {
errors.push('Achievements must have unique thresholds');
}
// Check for invalid threshold values
formData.achievements.forEach((achievement, i) => {
if (!achievement.name || !achievement.name.trim()) {
errors.push(`Achievement ${i + 1} is missing a name`);
}
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
}
});
// Warn if achievements are not in ascending order (they should be sorted)
for (let i = 1; i < formData.achievements.length; i++) {
if (formData.achievements[i].threshold < formData.achievements[i - 1].threshold) {
warnings.push('Achievements are not in ascending order by threshold');
break;
}
}
// Check if first achievement threshold equals or is less than the base target
const baseTarget = formData.rules?.target;
if (baseTarget && formData.achievements.length > 0) {
const firstThreshold = formData.achievements[0].threshold;
if (firstThreshold < baseTarget) {
warnings.push(`First achievement threshold (${firstThreshold}) is less than base target (${baseTarget}) - this may be intentional for "milestone below target" achievements`);
}
}
}
function performCrossFieldValidation(errors, warnings) { function performCrossFieldValidation(errors, warnings) {
// Check if filters contradict satellite_only // Check if filters contradict satellite_only
if (formData.rules.satellite_only && formData.rules.filters) { if (formData.rules.satellite_only && formData.rules.filters) {
@@ -434,6 +509,48 @@
performSafetyValidation(); performSafetyValidation();
} }
// Achievements management
function addAchievement() {
if (!newAchievementName.trim()) {
alert('Please enter an achievement name');
return;
}
if (!newAchievementThreshold || newAchievementThreshold <= 0) {
alert('Please enter a valid threshold (positive number)');
return;
}
// Check for duplicate threshold
const exists = formData.achievements?.some(a => a.threshold === newAchievementThreshold);
if (exists) {
alert('An achievement with this threshold already exists');
return;
}
formData = {
...formData,
achievements: [
...(formData.achievements || []),
{ name: newAchievementName.trim(), threshold: newAchievementThreshold }
]
};
// Sort achievements by threshold
formData.achievements.sort((a, b) => a.threshold - b.threshold);
newAchievementName = '';
newAchievementThreshold = 100;
performSafetyValidation();
}
function removeAchievement(index) {
formData = {
...formData,
achievements: formData.achievements.filter((_, i) => i !== index)
};
performSafetyValidation();
}
function testAward() { function testAward() {
showTestModal = true; showTestModal = true;
} }
@@ -492,6 +609,12 @@
> >
Mode Groups Mode Groups
</button> </button>
<button
class="tab {activeTab === 'achievements' ? 'active' : ''}"
on:click={() => activeTab = 'achievements'}
>
Achievements
</button>
<button <button
class="tab {activeTab === 'rules' ? 'active' : ''}" class="tab {activeTab === 'rules' ? 'active' : ''}"
on:click={() => activeTab = 'rules'} on:click={() => activeTab = 'rules'}
@@ -611,6 +734,60 @@
</div> </div>
{/if} {/if}
{#if activeTab === 'achievements'}
<div class="form-section">
<h2>Achievements</h2>
<p class="help-text">Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.</p>
{#if formData.achievements && formData.achievements.length > 0}
<div class="achievements-list">
<h3>Defined Achievements</h3>
{#each formData.achievements as achievement, i (i)}
<div class="achievement-item">
<div class="achievement-info">
<strong>{achievement.name}</strong>
<span class="achievement-threshold">{achievement.threshold} pts/entities</span>
</div>
<button class="btn-remove" on:click={() => removeAchievement(i)}>Remove</button>
</div>
{/each}
</div>
{:else}
<p class="empty-state">No achievements defined yet.</p>
{/if}
<div class="add-achievement">
<h3>Add Achievement</h3>
<div class="form-group">
<label for="achievement-name">Achievement Name</label>
<input
id="achievement-name"
type="text"
bind:value={newAchievementName}
placeholder="e.g., Silver, Gold, Platinum"
/>
</div>
<div class="form-group">
<label for="achievement-threshold">Threshold (entities/points required) *</label>
<input
id="achievement-threshold"
type="number"
min="1"
bind:value={newAchievementThreshold}
placeholder="e.g., 100, 200, 500"
/>
<small>The number of confirmed entities or points required to earn this achievement</small>
</div>
<button class="btn btn-secondary" on:click={addAchievement}>Add Achievement</button>
</div>
<div class="info-box">
<strong>Achievement Progress:</strong> Users will see earned achievements as gold badges. A progress bar shows progress toward the next achievement level.
Achievements are sorted by threshold (lowest first). The first achievement is typically at or above the base target.
</div>
</div>
{/if}
{#if activeTab === 'rules'} {#if activeTab === 'rules'}
<div class="form-section"> <div class="form-section">
<h2>Award Rules</h2> <h2>Award Rules</h2>
@@ -623,6 +800,7 @@
<option value="points">Points (sum points from stations)</option> <option value="points">Points (sum points from stations)</option>
<option value="filtered">Filtered (base rule with filters)</option> <option value="filtered">Filtered (base rule with filters)</option>
<option value="counter">Counter (count QSOs or callsigns)</option> <option value="counter">Counter (count QSOs or callsigns)</option>
<option value="wae">WAE (Worked All Europe)</option>
</select> </select>
</div> </div>
@@ -653,7 +831,7 @@
bind:value={formData.rules.displayField} bind:value={formData.rules.displayField}
placeholder="e.g., entity, state, grid" placeholder="e.g., entity, state, grid"
/> />
<small>Field to display as entity name (defaults to entity value)</small> <small>Field to display as entity name (defaults based on entityType)</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -844,6 +1022,93 @@
/> />
</div> </div>
</div> </div>
{:else if formData.rules.type === 'wae'}
<div class="rule-config">
<h3>WAE Rule Configuration</h3>
<div class="form-group">
<label>Target Countries *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetCountries}
placeholder="40"
/>
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
</div>
<div class="form-group">
<label>Target Bandpoints *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetBandpoints}
placeholder="100"
/>
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
</div>
<div class="form-group">
<label>Double-Point Bands</label>
<div class="bands-selector">
{#each ['160m', '80m'] as band}
<label class="band-checkbox">
<input
type="checkbox"
checked={formData.rules.doublePointBands?.includes(band)}
on:change={(e) => {
if (!formData.rules.doublePointBands) {
formData = {
...formData,
rules: {
...formData.rules,
doublePointBands: []
}
};
}
if (e.target.checked) {
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
} else {
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
}
}}
/>
{band}
</label>
{/each}
</div>
<small>Bands that count double points (typically 160m and 80m)</small>
</div>
<div class="form-group">
<label>Maximum Bands Per Country *</label>
<input
type="number"
min="1"
max="10"
bind:value={formData.rules.maxBandsPerCountry}
placeholder="5"
/>
<small>Only top N bands count per country (default: 5)</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
bind:checked={formData.rules.excludeDeletedForTop}
/>
Exclude deleted countries for WAE TOP/Trophy
</label>
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
</div>
<div class="info-box">
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
with a maximum of 5 bands per country.
</div>
</div>
{/if} {/if}
<!-- Filters section (common to all rule types) --> <!-- Filters section (common to all rule types) -->
@@ -872,7 +1137,7 @@
<style> <style>
.award-editor { .award-editor {
padding: 2rem; padding: 2rem;
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -880,7 +1145,7 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.2rem; font-size: 1.2rem;
color: #666; color: var(--text-secondary);
} }
.header { .header {
@@ -894,7 +1159,7 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.header-actions { .header-actions {
@@ -903,20 +1168,20 @@
} }
.error, .validation-errors { .error, .validation-errors {
background-color: #fee; background-color: var(--color-error-bg);
border: 1px solid #fcc; border: 1px solid var(--color-error);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #c00; color: var(--color-error);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.validation-warnings { .validation-warnings {
background-color: #fff3cd; background-color: var(--color-warning-bg);
border: 1px solid #ffc107; border: 1px solid var(--color-warning);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #856404; color: var(--color-warning);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -934,7 +1199,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-bottom: 2px solid #ddd; border-bottom: 2px solid var(--border-color);
} }
.tab { .tab {
@@ -944,25 +1209,25 @@
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #666; color: var(--text-secondary);
position: relative; position: relative;
top: 2px; top: 2px;
} }
.tab:hover { .tab:hover {
color: #333; color: var(--text-primary);
} }
.tab.active { .tab.active {
color: #667eea; color: var(--color-primary);
border-bottom: 2px solid #667eea; border-bottom: 2px solid var(--color-primary);
} }
.form-section { .form-section {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.form-group { .form-group {
@@ -973,7 +1238,7 @@
display: block; display: block;
font-weight: 500; font-weight: 500;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #333; color: var(--text-primary);
} }
.form-group input[type="text"], .form-group input[type="text"],
@@ -982,9 +1247,11 @@
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.6rem; padding: 0.6rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.form-group textarea { .form-group textarea {
@@ -995,17 +1262,18 @@
.form-group small { .form-group small {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
color: #888; color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
.btn { .btn {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-inverted);
} }
.btn:disabled { .btn:disabled {
@@ -1014,17 +1282,15 @@
} }
.btn-primary { .btn-primary {
background-color: #667eea; background-color: var(--color-primary);
color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #5568d3; background-color: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: #6c757d;
color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -1032,13 +1298,13 @@
} }
.btn-remove { .btn-remove {
background-color: #e74c3c; background-color: var(--color-error);
color: white;
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-inverted);
} }
.btn-remove:hover { .btn-remove:hover {
@@ -1046,7 +1312,7 @@
} }
.help-text { .help-text {
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -1059,10 +1325,10 @@
} }
.mode-group-item { .mode-group-item {
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
padding: 1rem; padding: 1rem;
background: #f9f9f9; background: var(--bg-secondary);
} }
.mode-group-header { .mode-group-header {
@@ -1073,17 +1339,70 @@
} }
.mode-group-modes { .mode-group-modes {
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
.add-mode-group { .add-mode-group {
border-top: 1px solid #ddd; border-top: 1px solid var(--border-color);
padding-top: 2rem; padding-top: 2rem;
} }
.add-mode-group h3 { .add-mode-group h3 {
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary);
}
/* Achievements styles */
.achievements-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.achievements-list h3 {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.achievement-item {
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1rem;
background: var(--bg-secondary);
}
.achievement-info {
display: flex;
align-items: center;
gap: 1rem;
}
.achievement-info strong {
color: var(--text-primary);
}
.achievement-threshold {
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
color: #5d4037;
padding: 0.25rem 0.75rem;
border-radius: var(--border-radius-pill);
font-size: 0.85rem;
font-weight: 600;
}
.add-achievement {
border-top: 1px solid var(--border-color);
padding-top: 2rem;
}
.add-achievement h3 {
margin-bottom: 1rem;
color: var(--text-primary);
} }
.mode-selector, .bands-selector { .mode-selector, .bands-selector {
@@ -1099,6 +1418,7 @@
gap: 0.5rem; gap: 0.5rem;
padding: 0.25rem; padding: 0.25rem;
cursor: pointer; cursor: pointer;
color: var(--text-primary);
} }
.mode-checkbox input, .band-checkbox input { .mode-checkbox input, .band-checkbox input {
@@ -1106,24 +1426,25 @@
} }
.rule-config { .rule-config {
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
background: #f9f9f9; background: var(--bg-secondary);
} }
.rule-config h3 { .rule-config h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary);
} }
.info-box { .info-box {
background-color: #e3f2fd; background-color: var(--color-info-bg);
border-left: 4px solid #2196f3; border-left: 4px solid var(--color-info);
padding: 1rem; padding: 1rem;
margin-top: 1rem; margin-top: 1rem;
color: #0d47a1; color: var(--color-info);
} }
.stations-editor { .stations-editor {
@@ -1149,17 +1470,17 @@
.base-rule-config { .base-rule-config {
padding-left: 1rem; padding-left: 1rem;
border-left: 3px solid #ddd; border-left: 3px solid var(--border-color);
} }
.filters-section { .filters-section {
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid #ddd; border-top: 1px solid var(--border-color);
} }
.empty-state { .empty-state {
color: #888; color: var(--text-muted);
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;

View File

@@ -283,16 +283,16 @@
<style> <style>
.filter-builder { .filter-builder {
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
padding: 1.5rem; padding: 1.5rem;
background: #fafafa; background: var(--bg-secondary);
} }
.no-filters { .no-filters {
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
color: #666; color: var(--text-secondary);
} }
.filter-group { .filter-group {
@@ -310,7 +310,7 @@
.filter-group-header h4 { .filter-group-header h4 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.operator-selector { .operator-selector {
@@ -323,6 +323,7 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
cursor: pointer; cursor: pointer;
color: var(--text-primary);
} }
.filter-list { .filter-list {
@@ -332,9 +333,9 @@
} }
.filter-item { .filter-item {
background: white; background: var(--bg-card);
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
padding: 1rem; padding: 1rem;
} }
@@ -348,9 +349,11 @@
.single-filter select, .single-filter select,
.single-filter input { .single-filter input {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.field-select { .field-select {
@@ -372,7 +375,7 @@
} }
.nested-filter-group { .nested-filter-group {
border-left: 3px solid #667eea; border-left: 3px solid var(--color-primary);
padding-left: 1rem; padding-left: 1rem;
} }
@@ -385,7 +388,7 @@
.group-label { .group-label {
font-weight: 500; font-weight: 500;
color: #667eea; color: var(--color-primary);
} }
.multi-select { .multi-select {
@@ -399,10 +402,11 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: #f5f5f5; background: var(--bg-hover);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-primary);
} }
.checkbox-option input { .checkbox-option input {
@@ -410,11 +414,11 @@
} }
.btn-remove { .btn-remove {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: var(--text-inverted);
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -429,21 +433,21 @@
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid #ddd; border-top: 1px solid var(--border-color);
} }
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-inverted);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: #6c757d;
color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -451,8 +455,7 @@
} }
.btn-danger { .btn-danger {
background-color: #dc3545; background-color: var(--color-error);
color: white;
} }
.btn-danger:hover { .btn-danger:hover {

View File

@@ -197,15 +197,21 @@
} }
// Check if displayField matches the default for the entity type // Check if displayField matches the default for the entity type
if (rules.entityType && rules.displayField) { if (rules.entityType) {
const defaults = { const defaults = {
'dxcc': 'entity', 'dxcc': 'entity',
'state': 'state', 'state': 'state',
'grid': 'grid', 'grid': 'grid',
'callsign': 'callsign' 'callsign': 'callsign'
}; };
if (defaults[rules.entityType] === rules.displayField) { const defaultField = defaults[rules.entityType];
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
if (rules.displayField) {
if (defaultField === rules.displayField) {
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
}
} else if (defaultField) {
info.push(`displayField will default to "${defaultField}" for entityType="${rules.entityType}".`);
} }
} }
@@ -525,15 +531,15 @@
} }
.modal-content { .modal-content {
background: white; background: var(--bg-card);
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 800px; max-width: 800px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.modal-content.large { .modal-content.large {
@@ -545,12 +551,12 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.close-btn { .close-btn {
@@ -558,7 +564,7 @@
border: none; border: none;
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
color: #888; color: var(--text-muted);
padding: 0; padding: 0;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@@ -568,7 +574,7 @@
} }
.close-btn:hover { .close-btn:hover {
color: #333; color: var(--text-primary);
} }
.modal-body { .modal-body {
@@ -583,38 +589,39 @@
.validation-section h3 { .validation-section h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
} }
.validation-block { .validation-block {
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.validation-block h4 { .validation-block h4 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
font-size: 1rem; font-size: 1rem;
color: var(--text-primary);
} }
.validation-block.errors { .validation-block.errors {
background-color: #fee; background-color: var(--color-error-bg);
border-left: 4px solid #dc3545; border-left: 4px solid var(--color-error);
} }
.validation-block.warnings { .validation-block.warnings {
background-color: #fff3cd; background-color: var(--color-warning-bg);
border-left: 4px solid #ffc107; border-left: 4px solid var(--color-warning);
} }
.validation-block.info { .validation-block.info {
background-color: #e3f2fd; background-color: var(--color-info-bg);
border-left: 4px solid #2196f3; border-left: 4px solid var(--color-info);
} }
.validation-block.success { .validation-block.success {
background-color: #d4edda; background-color: var(--color-success-bg);
border-left: 4px solid #28a745; border-left: 4px solid var(--color-success);
} }
.validation-block ul { .validation-block ul {
@@ -624,32 +631,33 @@
.validation-block li { .validation-block li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--text-primary);
} }
.severity-error { .severity-error {
color: #dc3545; color: var(--color-error);
} }
.severity-warning { .severity-warning {
color: #856404; color: var(--color-warning);
} }
.severity-info { .severity-info {
color: #0d47a1; color: var(--color-info);
} }
.test-config { .test-config {
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border-color);
padding-top: 1.5rem; padding-top: 1.5rem;
} }
.test-config h3 { .test-config h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: #333; color: var(--text-primary);
} }
.help-text { .help-text {
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -663,33 +671,37 @@
.user-selector label { .user-selector label {
font-weight: 500; font-weight: 500;
color: var(--text-primary);
} }
.user-selector select { .user-selector select {
flex: 1; flex: 1;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
background: var(--bg-input);
color: var(--text-primary);
} }
.test-results { .test-results {
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.test-results.error { .test-results.error {
background-color: #fee; background-color: var(--color-error-bg);
border-left: 4px solid #dc3545; border-left: 4px solid var(--color-error);
} }
.test-results.success { .test-results.success {
background-color: #f9f9f9; background-color: var(--bg-secondary);
border: 1px solid #ddd; border: 1px solid var(--border-color);
} }
.test-results h4 { .test-results h4 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: var(--text-primary);
} }
.result-summary { .result-summary {
@@ -704,40 +716,40 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 0.75rem; padding: 0.75rem;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
} }
.result-item .label { .result-item .label {
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.result-item .value { .result-item .value {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
color: #333; color: var(--text-primary);
} }
.result-item .value.confirmed { .result-item .value.confirmed {
color: #28a745; color: var(--color-success);
} }
.result-item .value.progress { .result-item .value.progress {
color: #667eea; color: var(--color-primary);
} }
.result-warnings { .result-warnings {
background-color: #fff3cd; background-color: var(--color-warning-bg);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: var(--border-radius);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.result-warnings h5 { .result-warnings h5 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: #856404; color: var(--color-warning);
} }
.result-warnings ul { .result-warnings ul {
@@ -746,14 +758,14 @@
} }
.sample-entities { .sample-entities {
background-color: #e3f2fd; background-color: var(--color-info-bg);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.sample-entities h5 { .sample-entities h5 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: #0d47a1; color: var(--color-info);
} }
.entities-list { .entities-list {
@@ -763,24 +775,24 @@
} }
.entity-tag { .entity-tag {
background-color: white; background-color: var(--bg-card);
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: 12px;
font-size: 0.85rem; font-size: 0.85rem;
color: #0d47a1; color: var(--color-info);
} }
.no-matches { .no-matches {
background-color: #fff3cd; background-color: var(--color-warning-bg);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: var(--border-radius);
text-align: center; text-align: center;
color: #856404; color: var(--color-warning);
} }
.modal-footer { .modal-footer {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border-color);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -788,10 +800,11 @@
.btn { .btn {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-inverted);
} }
.btn:disabled { .btn:disabled {
@@ -800,17 +813,15 @@
} }
.btn-primary { .btn-primary {
background-color: #667eea; background-color: var(--color-primary);
color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #5568d3; background-color: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: #6c757d;
color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {

View File

@@ -17,7 +17,7 @@
// UI state // UI state
let showTestModal = false; let showTestModal = false;
let activeTab = 'basic'; // basic, modeGroups, rules let activeTab = 'basic'; // basic, modeGroups, achievements, rules
// Form data // Form data
let formData = { let formData = {
@@ -27,16 +27,36 @@
caption: '', caption: '',
category: '', category: '',
modeGroups: {}, modeGroups: {},
achievements: [],
rules: { rules: {
type: 'entity', type: 'entity',
} }
}; };
// Achievements editor state
let newAchievementName = '';
let newAchievementThreshold = 100;
// Update stations store when formData changes // Update stations store when formData changes
$: if (formData.rules?.stations) { $: if (formData.rules?.stations) {
stationsStore.set(formData.rules.stations.map(s => ({...s}))); stationsStore.set(formData.rules.stations.map(s => ({...s})));
} }
// Initialize WAE-specific fields when rule type changes to 'wae' (only for new awards, not editing)
$: if (formData.rules?.type === 'wae' && formData.rules.targetCountries === undefined && !isEdit) {
formData = {
...formData,
rules: {
...formData.rules,
targetCountries: 40,
targetBandpoints: 100,
doublePointBands: ['160m', '80m'],
maxBandsPerCountry: 5,
excludeDeletedForTop: true,
}
};
}
// Available bands and modes // Available bands and modes
const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm']; const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144']; const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
@@ -52,12 +72,11 @@
onMount(() => { onMount(() => {
// Check if we're editing an existing award // Check if we're editing an existing award
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
if (pathParts.includes('[id]') || pathParts.length > 5) { const lastPart = pathParts[pathParts.length - 1];
// Extract award ID from path
const idPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2]; // If the last part is not 'create' and looks like an award ID, load the award
if (idPart && idPart !== 'create') { if (lastPart && lastPart !== 'create' && lastPart !== 'awards') {
loadAward(idPart); loadAward(lastPart);
}
} }
}); });
@@ -72,6 +91,7 @@
caption: data.award.caption || '', caption: data.award.caption || '',
category: data.award.category || '', category: data.award.category || '',
modeGroups: data.award.modeGroups || {}, modeGroups: data.award.modeGroups || {},
achievements: data.award.achievements || [],
rules: data.award.rules || { type: 'entity' }, rules: data.award.rules || { type: 'entity' },
}; };
awardId = id; awardId = id;
@@ -122,11 +142,17 @@
case 'counter': case 'counter':
validateCounterRule(errors, warnings); validateCounterRule(errors, warnings);
break; break;
case 'wae':
validateWAERule(errors, warnings);
break;
} }
// Mode groups validation // Mode groups validation
validateModeGroups(errors, warnings); validateModeGroups(errors, warnings);
// Achievements validation
validateAchievements(errors, warnings);
// Cross-field validation // Cross-field validation
performCrossFieldValidation(errors, warnings); performCrossFieldValidation(errors, warnings);
@@ -234,6 +260,29 @@
} }
} }
function validateWAERule(errors, warnings) {
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
errors.push('WAE rule requires targetCountries (positive number)');
}
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
errors.push('WAE rule requires targetBandpoints (positive number)');
}
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
}
// Check that double-point bands are valid
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
if (invalid.length > 0) {
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
}
}
}
function validateModeGroups(errors, warnings) { function validateModeGroups(errors, warnings) {
if (!formData.modeGroups) return; if (!formData.modeGroups) return;
@@ -277,6 +326,46 @@
} }
} }
function validateAchievements(errors, warnings) {
if (!formData.achievements || formData.achievements.length === 0) {
return; // Achievements are optional
}
// Check for duplicate thresholds
const thresholds = formData.achievements.map(a => a.threshold);
const uniqueThresholds = new Set(thresholds);
if (thresholds.length !== uniqueThresholds.size) {
errors.push('Achievements must have unique thresholds');
}
// Check for invalid threshold values
formData.achievements.forEach((achievement, i) => {
if (!achievement.name || !achievement.name.trim()) {
errors.push(`Achievement ${i + 1} is missing a name`);
}
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
}
});
// Warn if achievements are not in ascending order (they should be sorted)
for (let i = 1; i < formData.achievements.length; i++) {
if (formData.achievements[i].threshold < formData.achievements[i - 1].threshold) {
warnings.push('Achievements are not in ascending order by threshold');
break;
}
}
// Check if first achievement threshold equals or is less than the base target
const baseTarget = formData.rules?.target;
if (baseTarget && formData.achievements.length > 0) {
const firstThreshold = formData.achievements[0].threshold;
if (firstThreshold < baseTarget) {
warnings.push(`First achievement threshold (${firstThreshold}) is less than base target (${baseTarget}) - this may be intentional for "milestone below target" achievements`);
}
}
}
function performCrossFieldValidation(errors, warnings) { function performCrossFieldValidation(errors, warnings) {
// Check if filters contradict satellite_only // Check if filters contradict satellite_only
if (formData.rules.satellite_only && formData.rules.filters) { if (formData.rules.satellite_only && formData.rules.filters) {
@@ -445,6 +534,48 @@
performSafetyValidation(); performSafetyValidation();
} }
// Achievements management
function addAchievement() {
if (!newAchievementName.trim()) {
alert('Please enter an achievement name');
return;
}
if (!newAchievementThreshold || newAchievementThreshold <= 0) {
alert('Please enter a valid threshold (positive number)');
return;
}
// Check for duplicate threshold
const exists = formData.achievements?.some(a => a.threshold === newAchievementThreshold);
if (exists) {
alert('An achievement with this threshold already exists');
return;
}
formData = {
...formData,
achievements: [
...(formData.achievements || []),
{ name: newAchievementName.trim(), threshold: newAchievementThreshold }
]
};
// Sort achievements by threshold
formData.achievements.sort((a, b) => a.threshold - b.threshold);
newAchievementName = '';
newAchievementThreshold = 100;
performSafetyValidation();
}
function removeAchievement(index) {
formData = {
...formData,
achievements: formData.achievements.filter((_, i) => i !== index)
};
performSafetyValidation();
}
function testAward() { function testAward() {
showTestModal = true; showTestModal = true;
} }
@@ -514,6 +645,12 @@
> >
Mode Groups Mode Groups
</button> </button>
<button
class="tab {activeTab === 'achievements' ? 'active' : ''}"
on:click={() => activeTab = 'achievements'}
>
Achievements
</button>
<button <button
class="tab {activeTab === 'rules' ? 'active' : ''}" class="tab {activeTab === 'rules' ? 'active' : ''}"
on:click={() => activeTab = 'rules'} on:click={() => activeTab = 'rules'}
@@ -636,6 +773,60 @@
</div> </div>
{/if} {/if}
{#if activeTab === 'achievements'}
<div class="form-section">
<h2>Achievements</h2>
<p class="help-text">Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.</p>
{#if formData.achievements && formData.achievements.length > 0}
<div class="achievements-list">
<h3>Defined Achievements</h3>
{#each formData.achievements as achievement, i (i)}
<div class="achievement-item">
<div class="achievement-info">
<strong>{achievement.name}</strong>
<span class="achievement-threshold">{achievement.threshold} pts/entities</span>
</div>
<button class="btn-remove" on:click={() => removeAchievement(i)}>Remove</button>
</div>
{/each}
</div>
{:else}
<p class="empty-state">No achievements defined yet.</p>
{/if}
<div class="add-achievement">
<h3>Add Achievement</h3>
<div class="form-group">
<label for="achievement-name">Achievement Name</label>
<input
id="achievement-name"
type="text"
bind:value={newAchievementName}
placeholder="e.g., Silver, Gold, Platinum"
/>
</div>
<div class="form-group">
<label for="achievement-threshold">Threshold (entities/points required) *</label>
<input
id="achievement-threshold"
type="number"
min="1"
bind:value={newAchievementThreshold}
placeholder="e.g., 100, 200, 500"
/>
<small>The number of confirmed entities or points required to earn this achievement</small>
</div>
<button class="btn btn-secondary" on:click={addAchievement}>Add Achievement</button>
</div>
<div class="info-box">
<strong>Achievement Progress:</strong> Users will see earned achievements as gold badges. A progress bar shows progress toward the next achievement level.
Achievements are sorted by threshold (lowest first). The first achievement is typically at or above the base target.
</div>
</div>
{/if}
{#if activeTab === 'rules'} {#if activeTab === 'rules'}
<div class="form-section"> <div class="form-section">
<h2>Award Rules</h2> <h2>Award Rules</h2>
@@ -648,6 +839,7 @@
<option value="points">Points (sum points from stations)</option> <option value="points">Points (sum points from stations)</option>
<option value="filtered">Filtered (base rule with filters)</option> <option value="filtered">Filtered (base rule with filters)</option>
<option value="counter">Counter (count QSOs or callsigns)</option> <option value="counter">Counter (count QSOs or callsigns)</option>
<option value="wae">WAE (Worked All Europe)</option>
</select> </select>
</div> </div>
@@ -678,7 +870,7 @@
bind:value={formData.rules.displayField} bind:value={formData.rules.displayField}
placeholder="e.g., entity, state, grid" placeholder="e.g., entity, state, grid"
/> />
<small>Field to display as entity name (defaults to entity value)</small> <small>Field to display as entity name (defaults based on entityType)</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -870,6 +1062,93 @@
/> />
</div> </div>
</div> </div>
{:else if formData.rules.type === 'wae'}
<div class="rule-config">
<h3>WAE Rule Configuration</h3>
<div class="form-group">
<label>Target Countries *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetCountries}
placeholder="40"
/>
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
</div>
<div class="form-group">
<label>Target Bandpoints *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetBandpoints}
placeholder="100"
/>
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
</div>
<div class="form-group">
<label>Double-Point Bands</label>
<div class="bands-selector">
{#each ['160m', '80m'] as band}
<label class="band-checkbox">
<input
type="checkbox"
checked={formData.rules.doublePointBands?.includes(band)}
on:change={(e) => {
if (!formData.rules.doublePointBands) {
formData = {
...formData,
rules: {
...formData.rules,
doublePointBands: []
}
};
}
if (e.target.checked) {
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
} else {
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
}
}}
/>
{band}
</label>
{/each}
</div>
<small>Bands that count double points (typically 160m and 80m)</small>
</div>
<div class="form-group">
<label>Maximum Bands Per Country *</label>
<input
type="number"
min="1"
max="10"
bind:value={formData.rules.maxBandsPerCountry}
placeholder="5"
/>
<small>Only top N bands count per country (default: 5)</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
bind:checked={formData.rules.excludeDeletedForTop}
/>
Exclude deleted countries for WAE TOP/Trophy
</label>
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
</div>
<div class="info-box">
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
with a maximum of 5 bands per country.
</div>
</div>
{/if} {/if}
<!-- Filters section (common to all rule types) --> <!-- Filters section (common to all rule types) -->
@@ -898,7 +1177,7 @@
<style> <style>
.award-editor { .award-editor {
padding: 2rem; padding: 2rem;
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -906,7 +1185,7 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.2rem; font-size: 1.2rem;
color: #666; color: var(--text-secondary);
} }
.header { .header {
@@ -920,7 +1199,7 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.header-actions { .header-actions {
@@ -929,20 +1208,20 @@
} }
.error, .validation-errors { .error, .validation-errors {
background-color: #fee; background-color: var(--color-error-bg);
border: 1px solid #fcc; border: 1px solid var(--color-error);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #c00; color: var(--color-error);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.validation-warnings { .validation-warnings {
background-color: #fff3cd; background-color: var(--color-warning-bg);
border: 1px solid #ffc107; border: 1px solid var(--color-warning);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #856404; color: var(--color-warning);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -960,7 +1239,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-bottom: 2px solid #ddd; border-bottom: 2px solid var(--border-color);
} }
.tab { .tab {
@@ -970,25 +1249,25 @@
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #666; color: var(--text-secondary);
position: relative; position: relative;
top: 2px; top: 2px;
} }
.tab:hover { .tab:hover {
color: #333; color: var(--text-primary);
} }
.tab.active { .tab.active {
color: #667eea; color: var(--color-primary);
border-bottom: 2px solid #667eea; border-bottom: 2px solid var(--color-primary);
} }
.form-section { .form-section {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.form-group { .form-group {
@@ -999,7 +1278,7 @@
display: block; display: block;
font-weight: 500; font-weight: 500;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #333; color: var(--text-primary);
} }
.form-group input[type="text"], .form-group input[type="text"],
@@ -1008,9 +1287,11 @@
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.6rem; padding: 0.6rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.form-group textarea { .form-group textarea {
@@ -1021,17 +1302,18 @@
.form-group small { .form-group small {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
color: #888; color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
.btn { .btn {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-inverted);
} }
.btn:disabled { .btn:disabled {
@@ -1040,17 +1322,15 @@
} }
.btn-primary { .btn-primary {
background-color: #667eea; background-color: var(--color-primary);
color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #5568d3; background-color: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: #6c757d;
color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -1058,13 +1338,13 @@
} }
.btn-remove { .btn-remove {
background-color: #e74c3c; background-color: var(--color-error);
color: white;
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-inverted);
} }
.btn-remove:hover { .btn-remove:hover {
@@ -1072,7 +1352,7 @@
} }
.help-text { .help-text {
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -1085,10 +1365,10 @@
} }
.mode-group-item { .mode-group-item {
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
padding: 1rem; padding: 1rem;
background: #f9f9f9; background: var(--bg-secondary);
} }
.mode-group-header { .mode-group-header {
@@ -1099,17 +1379,70 @@
} }
.mode-group-modes { .mode-group-modes {
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
.add-mode-group { .add-mode-group {
border-top: 1px solid #ddd; border-top: 1px solid var(--border-color);
padding-top: 2rem; padding-top: 2rem;
} }
.add-mode-group h3 { .add-mode-group h3 {
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary);
}
/* Achievements styles */
.achievements-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.achievements-list h3 {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.achievement-item {
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1rem;
background: var(--bg-secondary);
}
.achievement-info {
display: flex;
align-items: center;
gap: 1rem;
}
.achievement-info strong {
color: var(--text-primary);
}
.achievement-threshold {
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
color: #5d4037;
padding: 0.25rem 0.75rem;
border-radius: var(--border-radius-pill);
font-size: 0.85rem;
font-weight: 600;
}
.add-achievement {
border-top: 1px solid var(--border-color);
padding-top: 2rem;
}
.add-achievement h3 {
margin-bottom: 1rem;
color: var(--text-primary);
} }
.mode-selector, .bands-selector { .mode-selector, .bands-selector {
@@ -1125,6 +1458,7 @@
gap: 0.5rem; gap: 0.5rem;
padding: 0.25rem; padding: 0.25rem;
cursor: pointer; cursor: pointer;
color: var(--text-primary);
} }
.mode-checkbox input, .band-checkbox input { .mode-checkbox input, .band-checkbox input {
@@ -1132,24 +1466,25 @@
} }
.rule-config { .rule-config {
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
background: #f9f9f9; background: var(--bg-secondary);
} }
.rule-config h3 { .rule-config h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary);
} }
.info-box { .info-box {
background-color: #e3f2fd; background-color: var(--color-info-bg);
border-left: 4px solid #2196f3; border-left: 4px solid var(--color-info);
padding: 1rem; padding: 1rem;
margin-top: 1rem; margin-top: 1rem;
color: #0d47a1; color: var(--color-info);
} }
.stations-editor { .stations-editor {
@@ -1175,17 +1510,17 @@
.base-rule-config { .base-rule-config {
padding-left: 1rem; padding-left: 1rem;
border-left: 3px solid #ddd; border-left: 3px solid var(--border-color);
} }
.filters-section { .filters-section {
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid #ddd; border-top: 1px solid var(--border-color);
} }
.empty-state { .empty-state {
color: #888; color: var(--text-muted);
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;

View File

@@ -173,20 +173,20 @@
<style> <style>
.container { .container {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
} }
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.subtitle { .subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: #666; color: var(--text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -205,28 +205,29 @@
.filter-group label { .filter-group label {
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 0.9rem; font-size: 0.9rem;
} }
.filter-group select { .filter-group select {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid #ccc; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
background-color: white; background-color: var(--bg-input);
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
min-width: 150px; min-width: 150px;
color: var(--text-primary);
} }
.filter-group select:hover { .filter-group select:hover {
border-color: #4a90e2; border-color: var(--color-primary);
} }
.filter-group select:focus { .filter-group select:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); box-shadow: var(--focus-ring);
} }
.loading, .loading,
@@ -235,11 +236,11 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
color: #d32f2f; color: var(--color-error);
} }
.awards-grid { .awards-grid {
@@ -249,11 +250,11 @@
} }
.award-card { .award-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -268,16 +269,16 @@
.award-header h2 { .award-header h2 {
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
.category { .category {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--border-radius-pill);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -285,7 +286,7 @@
} }
.description { .description {
color: #666; color: var(--text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
flex-grow: 1; flex-grow: 1;
} }
@@ -295,8 +296,8 @@
gap: 2rem; gap: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
background-color: #f8f9fa; background-color: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-item { .info-item {
@@ -307,14 +308,14 @@
.label { .label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
} }
.value { .value {
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -325,15 +326,15 @@
.progress-bar { .progress-bar {
width: 100%; width: 100%;
height: 8px; height: 8px;
background-color: #e0e0e0; background-color: var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #4a90e2 0%, #357abd 100%); background: var(--gradient-primary);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -348,7 +349,7 @@
text-align: center; text-align: center;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
.worked, .worked,
@@ -357,20 +358,20 @@
} }
.worked { .worked {
color: #666; color: var(--text-secondary);
} }
.confirmed { .confirmed {
color: #4a90e2; color: var(--color-primary);
} }
.btn { .btn {
display: inline-block; display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -379,6 +380,6 @@
} }
.btn:hover { .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -877,7 +877,7 @@
<style> <style>
.container { .container {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
@@ -899,22 +899,22 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.header-buttons { .header-buttons {
@@ -924,16 +924,16 @@
} }
.filters { .filters {
background: #f8f9fa; background: var(--bg-secondary);
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.filters h3 { .filters h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
} }
.filters-header { .filters-header {
@@ -947,11 +947,11 @@
.filter-count { .filter-count {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--text-secondary);
} }
.filter-count strong { .filter-count strong {
color: #4a90e2; color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }
@@ -964,24 +964,28 @@
.filter-row select { .filter-row select {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input:focus { .search-input:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); box-shadow: var(--focus-ring);
} }
.checkbox-label { .checkbox-label {
@@ -994,7 +998,7 @@
.btn { .btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -1002,12 +1006,12 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-primary:disabled { .btn-primary:disabled {
@@ -1016,21 +1020,21 @@
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.btn-danger { .btn-danger {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.btn-danger:disabled { .btn-danger:disabled {
@@ -1043,14 +1047,14 @@
font-size: 0.875rem; font-size: 0.875rem;
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
color: inherit; color: inherit;
} }
.alert { .alert {
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1058,21 +1062,21 @@
} }
.alert-success { .alert-success {
background-color: #d4edda; background-color: var(--color-success-bg);
border: 1px solid #c3e6cb; border: 1px solid var(--color-success);
color: #155724; color: var(--color-success);
} }
.alert-error { .alert-error {
background-color: #f8d7da; background-color: var(--color-error-bg);
border: 1px solid #f5c6cb; border: 1px solid var(--color-error);
color: #721c24; color: var(--color-error);
} }
.alert-info { .alert-info {
background-color: #d1ecf1; background-color: var(--color-info-bg);
border: 1px solid #bee5eb; border: 1px solid var(--color-info);
color: #0c5460; color: var(--color-info);
} }
.alert h3 { .alert h3 {
@@ -1090,11 +1094,13 @@
.delete-input { .delete-input {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
width: 200px; width: 200px;
background: var(--bg-input);
color: var(--text-primary);
} }
.delete-buttons { .delete-buttons {
@@ -1105,8 +1111,8 @@
.qso-table-container { .qso-table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
} }
.qso-table { .qso-table {
@@ -1118,39 +1124,39 @@
.qso-table td { .qso-table td {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.qso-table th { .qso-table th {
background-color: #f8f9fa; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
} }
.qso-table tr:hover { .qso-table tr:hover {
background-color: #f8f9fa; background-color: var(--bg-secondary);
} }
.callsign { .callsign {
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
.badge { .badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
.badge-success { .badge-success {
background-color: #d4edda; background-color: var(--color-success-bg);
color: #155724; color: var(--color-success);
} }
.badge-pending { .badge-pending {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
color: #856404; color: var(--badge-pending-text);
} }
.confirmation-list { .confirmation-list {
@@ -1168,29 +1174,29 @@
.service-type { .service-type {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.confirmation-date { .confirmation-date {
font-size: 0.875rem; font-size: 0.875rem;
color: #333; color: var(--text-primary);
} }
.loading, .error, .empty { .loading, .error, .empty {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
color: #dc3545; color: var(--color-error);
} }
.showing { .showing {
text-align: center; text-align: center;
color: #666; color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1201,14 +1207,14 @@
align-items: center; align-items: center;
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 1rem; padding: 1rem;
background: #f8f9fa; background: var(--bg-secondary);
border-radius: 8px; border-radius: var(--border-radius-lg);
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
} }
.pagination-info { .pagination-info {
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -1226,7 +1232,7 @@
.page-ellipsis { .page-ellipsis {
padding: 0 0.5rem; padding: 0 0.5rem;
color: #666; color: var(--text-secondary);
} }
.btn-small { .btn-small {
@@ -1241,16 +1247,16 @@
} }
.import-log { .import-log {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
} }
.import-log h3 { .import-log h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
font-size: 1.25rem; font-size: 1.25rem;
} }
@@ -1264,15 +1270,15 @@
.log-section h4 { .log-section h4 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
color: #555; color: var(--text-secondary);
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
} }
.log-table-container { .log-table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
} }
.log-table { .log-table {
@@ -1285,13 +1291,13 @@
.log-table td { .log-table td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.log-table th { .log-table th {
background-color: #f8f9fa; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -1300,12 +1306,12 @@
} }
.log-table tr:hover { .log-table tr:hover {
background-color: #f8f9fa; background-color: var(--bg-secondary);
} }
.log-table .callsign { .log-table .callsign {
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
/* QSO Detail Modal Styles */ /* QSO Detail Modal Styles */
@@ -1315,11 +1321,11 @@
} }
.qso-row:hover { .qso-row:hover {
background-color: #f0f7ff !important; background-color: var(--color-primary-light) !important;
} }
.qso-row:focus { .qso-row:focus {
outline: 2px solid #4a90e2; outline: 2px solid var(--color-primary);
outline-offset: -2px; outline-offset: -2px;
} }
@@ -1340,13 +1346,13 @@
/* Modal Content */ /* Modal Content */
.modal-content { .modal-content {
background: white; background: var(--bg-card);
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 700px; max-width: 700px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
/* Modal Header */ /* Modal Header */
@@ -1355,13 +1361,13 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
} }
.modal-close { .modal-close {
@@ -1376,14 +1382,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #666; color: var(--text-secondary);
border-radius: 4px; border-radius: var(--border-radius);
transition: all 0.2s; transition: all 0.2s;
} }
.modal-close:hover { .modal-close:hover {
background-color: #f0f0f0; background-color: var(--bg-tertiary);
color: #333; color: var(--text-primary);
} }
/* Modal Body */ /* Modal Body */
@@ -1402,10 +1408,10 @@
.qso-detail-section h3 { .qso-detail-section h3 {
font-size: 1.1rem; font-size: 1.1rem;
color: #4a90e2; color: var(--color-primary);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0; border-bottom: 2px solid var(--border-color);
} }
/* Detail Grid */ /* Detail Grid */
@@ -1423,14 +1429,14 @@
.detail-label { .detail-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
} }
.detail-value { .detail-value {
font-size: 0.95rem; font-size: 0.95rem;
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -1443,10 +1449,10 @@
.confirmation-service h4 { .confirmation-service h4 {
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.confirmation-status-item { .confirmation-status-item {
@@ -1466,19 +1472,19 @@
} }
.status-badge.confirmed { .status-badge.confirmed {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.status-badge.not-confirmed, .status-badge.not-confirmed,
.status-badge.no-data { .status-badge.no-data {
background-color: #e0e0e0; background-color: var(--border-color);
color: #666; color: var(--text-secondary);
} }
.status-badge.unknown { .status-badge.unknown {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
color: #856404; color: var(--badge-pending-text);
} }
/* Meta Info */ /* Meta Info */
@@ -1486,18 +1492,18 @@
display: flex; display: flex;
gap: 2rem; gap: 2rem;
padding: 1rem; padding: 1rem;
background-color: #f8f9fa; background-color: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.8rem; font-size: 0.8rem;
} }
.meta-label { .meta-label {
color: #666; color: var(--text-secondary);
font-weight: 600; font-weight: 600;
} }
.meta-value { .meta-value {
color: #333; color: var(--text-primary);
font-family: monospace; font-family: monospace;
} }
@@ -1507,15 +1513,15 @@
} }
.modal-content::-webkit-scrollbar-track { .modal-content::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--bg-secondary);
} }
.modal-content::-webkit-scrollbar-thumb { .modal-content::-webkit-scrollbar-thumb {
background: #888; background: var(--text-muted);
border-radius: 4px; border-radius: 4px;
} }
.modal-content::-webkit-scrollbar-thumb:hover { .modal-content::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--text-secondary);
} }
</style> </style>

View File

@@ -32,9 +32,9 @@
} }
.stat-card { .stat-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
text-align: center; text-align: center;
} }
@@ -42,11 +42,11 @@
.stat-value { .stat-value {
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
color: #4a90e2; color: var(--color-primary);
} }
.stat-label { .stat-label {
color: #666; color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
</style> </style>

View File

@@ -15,6 +15,7 @@
disabled={isRunning || deleting} disabled={isRunning || deleting}
> >
{#if isRunning} {#if isRunning}
<span class="spinner"></span>
{label} Syncing... {label} Syncing...
{:else} {:else}
Sync from {label} Sync from {label}
@@ -23,11 +24,11 @@
<style> <style>
.lotw-btn { .lotw-btn {
background-color: #4a90e2; background-color: var(--color-primary);
} }
.lotw-btn:hover:not(:disabled) { .lotw-btn:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.dcl-btn { .dcl-btn {
@@ -37,4 +38,22 @@
.dcl-btn:hover:not(:disabled) { .dcl-btn:hover:not(:disabled) {
background-color: #d35400; background-color: #d35400;
} }
/* Spinner animation */
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View File

@@ -440,40 +440,46 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: var(--text-inverted);
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.user-info { .user-info {
background: #f8f9fa; background: var(--bg-secondary);
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.user-info h2 { .user-info h2 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #333; color: var(--text-primary);
} }
.user-info p { .user-info p {
margin: 0.5rem 0; margin: 0.5rem 0;
color: #666; color: var(--text-secondary);
}
.user-info :global(strong),
.settings-form :global(strong),
.next-sync-info :global(strong) {
color: var(--text-primary);
} }
.settings-section { .settings-section {
@@ -483,44 +489,44 @@
.settings-section h2 { .settings-section h2 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #333; color: var(--text-primary);
} }
.help-text { .help-text {
color: #666; color: var(--text-secondary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
line-height: 1.6; line-height: 1.6;
} }
.alert { .alert {
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.alert-info { .alert-info {
background-color: #d1ecf1; background-color: var(--color-info-bg);
border: 1px solid #bee5eb; border: 1px solid var(--color-info);
color: #0c5460; color: var(--color-info-text);
} }
.alert-error { .alert-error {
background-color: #f8d7da; background-color: var(--color-error-bg);
border: 1px solid #f5c6cb; border: 1px solid var(--color-error);
color: #721c24; color: var(--color-error-text);
} }
.alert-success { .alert-success {
background-color: #d4edda; background-color: var(--color-success-bg);
border: 1px solid #c3e6cb; border: 1px solid var(--color-success);
color: #155724; color: var(--color-success);
} }
.settings-form { .settings-form {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -532,34 +538,36 @@
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;
color: #333; color: var(--text-primary);
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
box-sizing: border-box; box-sizing: border-box;
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1); box-shadow: var(--focus-ring);
} }
.hint { .hint {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.btn { .btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -567,12 +575,12 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: var(--text-inverted);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-primary:disabled { .btn-primary:disabled {
@@ -581,34 +589,34 @@
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: var(--text-inverted);
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.info-box { .info-box {
background: #e8f4fd; background: var(--color-info-bg);
border-left: 4px solid #4a90e2; border-left: 4px solid var(--color-primary);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-box h3 { .info-box h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
} }
.info-box p { .info-box p {
margin: 0.5rem 0; margin: 0.5rem 0;
color: #666; color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
} }
.info-box a { .info-box a {
color: #4a90e2; color: var(--text-link);
text-decoration: none; text-decoration: none;
} }
@@ -634,6 +642,7 @@
gap: 0.5rem; gap: 0.5rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
color: var(--text-primary);
} }
.checkbox-group input[type="checkbox"] { .checkbox-group input[type="checkbox"] {
@@ -649,18 +658,18 @@
.divider { .divider {
border: none; border: none;
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border-color);
margin: 2rem 0; margin: 2rem 0;
} }
.next-sync-info { .next-sync-info {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background-color: #e3f2fd; background-color: var(--color-info-bg);
border-left: 4px solid #4a90e2; border-left: 4px solid var(--color-primary);
border-radius: 4px; border-radius: var(--border-radius);
margin-top: 1rem; margin-top: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
color: #333; color: var(--text-primary);
} }
@media (max-width: 640px) { @media (max-width: 640px) {

View File

@@ -2,4 +2,5 @@
# Production start script # Production start script
# Run backend server (Elysia errors are harmless warnings that don't affect functionality) # Run backend server (Elysia errors are harmless warnings that don't affect functionality)
export LOG_LEVEL=debug
exec bun src/backend/index.js exec bun src/backend/index.js