Compare commits
18 Commits
b9b6afedb8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b296514356
|
|||
|
70858836d0
|
|||
|
257ebf6c5d
|
|||
|
caf7703073
|
|||
|
fa6420d149
|
|||
|
aa55158347
|
|||
|
|
e4e7f3c208 | ||
|
a35731f626
|
|||
|
2ae47232cb
|
|||
|
8b846bffbe
|
|||
|
ed433902d9
|
|||
|
a5f0e3b96f
|
|||
|
b09e2b3ea2
|
|||
|
239963ed89
|
|||
|
d1e4c39ad6
|
|||
|
24e0e3bfdb
|
|||
|
36453c8922
|
|||
|
bd89ea0855
|
73
CLAUDE.md
73
CLAUDE.md
@@ -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"
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
115
award-data/wae-country-list.json
Normal file
115
award-data/wae-country-list.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
award-definitions/qo100grids.json
Normal file
25
award-definitions/qo100grids.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "qo100grids",
|
||||||
|
"name": "QO100 Grids",
|
||||||
|
"description": "Work as much Grids as possible on QO100 Satellite",
|
||||||
|
"caption": "Work as much Grids as possible on QO100 Satellite",
|
||||||
|
"category": "satellite",
|
||||||
|
"rules": {
|
||||||
|
"type": "entity",
|
||||||
|
"satellite_only": true,
|
||||||
|
"filters": {
|
||||||
|
"operator": "AND",
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"field": "satName",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "QO-100"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entityType": "grid",
|
||||||
|
"target": 100,
|
||||||
|
"displayField": "grid"
|
||||||
|
},
|
||||||
|
"modeGroups": {}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "sat-rs44",
|
"id": "sat-rs44",
|
||||||
"name": "RS-44 Satellite",
|
"name": "44 on RS-44",
|
||||||
"description": "Work 44 QSOs on satellite RS-44",
|
"description": "Work 44 QSOs on satellite RS-44",
|
||||||
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
|
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
|
||||||
"category": "custom",
|
"category": "satellite",
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "counter",
|
"type": "counter",
|
||||||
"target": 44,
|
"target": 44,
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"modeGroups": {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
31
award-definitions/vucc6m.json
Normal file
31
award-definitions/vucc6m.json
Normal 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": []
|
||||||
|
}
|
||||||
33
award-definitions/wae.json
Normal file
33
award-definitions/wae.json
Normal 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
@@ -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/)
|
||||||
|
|||||||
17
drizzle/0004_overrated_havok.sql
Normal file
17
drizzle/0004_overrated_havok.sql
Normal 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;
|
||||||
868
drizzle/meta/0004_snapshot.json
Normal file
868
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -44,6 +45,14 @@ import {
|
|||||||
getAwardProgressDetails,
|
getAwardProgressDetails,
|
||||||
getAwardEntityBreakdown,
|
getAwardEntityBreakdown,
|
||||||
} from './services/awards.service.js';
|
} from './services/awards.service.js';
|
||||||
|
import {
|
||||||
|
getAllAwardDefinitions,
|
||||||
|
getAwardDefinition,
|
||||||
|
createAwardDefinition,
|
||||||
|
updateAwardDefinition,
|
||||||
|
deleteAwardDefinition,
|
||||||
|
testAwardCalculation,
|
||||||
|
} from './services/awards-admin.service.js';
|
||||||
import {
|
import {
|
||||||
getAutoSyncSettings,
|
getAutoSyncSettings,
|
||||||
updateAutoSyncSettings,
|
updateAutoSyncSettings,
|
||||||
@@ -199,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;
|
||||||
|
|
||||||
@@ -208,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,
|
||||||
@@ -343,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1192,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) {
|
||||||
@@ -1206,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,
|
||||||
@@ -1287,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,
|
||||||
});
|
});
|
||||||
@@ -1347,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1445,6 +1474,221 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================================================
|
||||||
|
* AWARD MANAGEMENT ROUTES (Admin Only)
|
||||||
|
* ================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/awards
|
||||||
|
* Get all award definitions (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/awards', async ({ user, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const awards = await getAllAwardDefinitions();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
awards,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching award definitions', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch award definitions',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/awards/:id
|
||||||
|
* Get a single award definition (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/awards/:id', async ({ user, params, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const award = await getAwardDefinition(params.id);
|
||||||
|
|
||||||
|
if (!award) {
|
||||||
|
set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Award not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching award definition', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch award definition',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/awards
|
||||||
|
* Create a new award definition (admin only)
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/api/admin/awards',
|
||||||
|
async ({ user, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const award = await createAwardDefinition(body);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
message: 'Award definition created successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating award definition', { error: error.message, userId: user.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
name: t.String(),
|
||||||
|
description: t.String(),
|
||||||
|
caption: t.String(),
|
||||||
|
category: t.String(),
|
||||||
|
rules: t.Any(),
|
||||||
|
modeGroups: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/awards/:id
|
||||||
|
* Update an award definition (admin only)
|
||||||
|
*/
|
||||||
|
.put(
|
||||||
|
'/api/admin/awards/:id',
|
||||||
|
async ({ user, params, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const award = await updateAwardDefinition(params.id, body);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
message: 'Award definition updated successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Optional(t.String()),
|
||||||
|
name: t.String(),
|
||||||
|
description: t.String(),
|
||||||
|
caption: t.String(),
|
||||||
|
category: t.String(),
|
||||||
|
rules: t.Any(),
|
||||||
|
modeGroups: t.Optional(t.Any()),
|
||||||
|
achievements: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/awards/:id
|
||||||
|
* Delete an award definition (admin only)
|
||||||
|
*/
|
||||||
|
.delete('/api/admin/awards/:id', async ({ user, params, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteAwardDefinition(params.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
message: 'Award definition deleted successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/awards/:id/test
|
||||||
|
* Test award calculation (admin only)
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/api/admin/awards/:id/test',
|
||||||
|
async ({ user, params, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use provided userId or admin's own account
|
||||||
|
const testUserId = body.userId || user.id;
|
||||||
|
const awardDefinition = body.awardDefinition || null;
|
||||||
|
const result = await testAwardCalculation(params.id, testUserId, awardDefinition);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
userId: t.Optional(t.Integer()),
|
||||||
|
awardDefinition: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ================================================================
|
* ================================================================
|
||||||
* AUTO-SYNC SETTINGS ROUTES
|
* AUTO-SYNC SETTINGS ROUTES
|
||||||
|
|||||||
86
src/backend/migrations/add-last-seen.js
Normal file
86
src/backend/migrations/add-last-seen.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
570
src/backend/services/awards-admin.service.js
Normal file
570
src/backend/services/awards-admin.service.js
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { logger } from '../config.js';
|
||||||
|
import { calculateAwardProgress, getAwardById, clearAwardCache } from './awards.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Awards Admin Service
|
||||||
|
* Manages award definition JSON files for admin operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
|
||||||
|
|
||||||
|
// Valid entity types for entity rule type
|
||||||
|
const VALID_ENTITY_TYPES = ['dxcc', 'state', 'grid', 'callsign'];
|
||||||
|
|
||||||
|
// Valid rule types
|
||||||
|
const VALID_RULE_TYPES = ['entity', 'dok', 'points', 'filtered', 'counter'];
|
||||||
|
|
||||||
|
// Valid count modes for points rule type
|
||||||
|
const VALID_COUNT_MODES = ['perStation', 'perBandMode', 'perQso'];
|
||||||
|
|
||||||
|
// Valid filter operators
|
||||||
|
const VALID_FILTER_OPERATORS = ['eq', 'ne', 'in', 'nin', 'contains'];
|
||||||
|
|
||||||
|
// Valid filter fields
|
||||||
|
const VALID_FILTER_FIELDS = [
|
||||||
|
'band', 'mode', 'callsign', 'entity', 'entityId', 'state', 'grid', 'satName', 'satellite'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Valid bands
|
||||||
|
const VALID_BANDS = [
|
||||||
|
'160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m',
|
||||||
|
'6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Valid modes
|
||||||
|
const VALID_MODES = [
|
||||||
|
'CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9',
|
||||||
|
'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all award definitions with file metadata
|
||||||
|
*/
|
||||||
|
export async function getAllAwardDefinitions() {
|
||||||
|
const definitions = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
||||||
|
.filter(f => f.endsWith('.json'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const filePath = join(AWARD_DEFINITIONS_DIR, file);
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const definition = JSON.parse(content);
|
||||||
|
|
||||||
|
// Add file metadata
|
||||||
|
definitions.push({
|
||||||
|
...definition,
|
||||||
|
_filename: file,
|
||||||
|
_filepath: filePath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to load award definition', { file, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error reading award definitions directory', { error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single award definition by ID
|
||||||
|
*/
|
||||||
|
export async function getAwardDefinition(id) {
|
||||||
|
const definitions = await getAllAwardDefinitions();
|
||||||
|
return definitions.find(def => def.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an award definition
|
||||||
|
* @returns {Object} { valid: boolean, errors: string[], warnings: string[] }
|
||||||
|
*/
|
||||||
|
export function validateAwardDefinition(definition, existingDefinitions = []) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
// Check required top-level fields
|
||||||
|
const requiredFields = ['id', 'name', 'description', 'caption', 'category', 'rules'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!definition[field]) {
|
||||||
|
errors.push(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ID
|
||||||
|
if (definition.id) {
|
||||||
|
if (typeof definition.id !== 'string') {
|
||||||
|
errors.push('ID must be a string');
|
||||||
|
} else if (!/^[a-z0-9-]+$/.test(definition.id)) {
|
||||||
|
errors.push('ID must contain only lowercase letters, numbers, and hyphens');
|
||||||
|
} else {
|
||||||
|
// Check for duplicate ID (unless updating existing award)
|
||||||
|
const existingIds = existingDefinitions.map(d => d.id);
|
||||||
|
const isUpdate = existingDefinitions.find(d => d.id === definition.id);
|
||||||
|
const duplicates = existingDefinitions.filter(d => d.id === definition.id);
|
||||||
|
if (duplicates.length > 1 || (duplicates.length === 1 && !isUpdate)) {
|
||||||
|
errors.push(`Award ID "${definition.id}" already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
if (definition.name && typeof definition.name !== 'string') {
|
||||||
|
errors.push('Name must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate description
|
||||||
|
if (definition.description && typeof definition.description !== 'string') {
|
||||||
|
errors.push('Description must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate caption
|
||||||
|
if (definition.caption && typeof definition.caption !== 'string') {
|
||||||
|
errors.push('Caption must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate category
|
||||||
|
if (definition.category && typeof definition.category !== '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
|
||||||
|
if (definition.modeGroups) {
|
||||||
|
if (typeof definition.modeGroups !== 'object') {
|
||||||
|
errors.push('modeGroups must be an object');
|
||||||
|
} else {
|
||||||
|
for (const [groupName, modes] of Object.entries(definition.modeGroups)) {
|
||||||
|
if (!Array.isArray(modes)) {
|
||||||
|
errors.push(`modeGroups "${groupName}" must be an array of mode strings`);
|
||||||
|
} else {
|
||||||
|
for (const mode of modes) {
|
||||||
|
if (typeof mode !== 'string') {
|
||||||
|
errors.push(`mode "${mode}" in group "${groupName}" must be a string`);
|
||||||
|
} else if (!VALID_MODES.includes(mode)) {
|
||||||
|
warnings.push(`Unknown mode "${mode}" in group "${groupName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rules
|
||||||
|
if (!definition.rules) {
|
||||||
|
errors.push('Rules object is required');
|
||||||
|
} else if (typeof definition.rules !== 'object') {
|
||||||
|
errors.push('Rules must be an object');
|
||||||
|
} else {
|
||||||
|
const ruleValidation = validateRules(definition.rules);
|
||||||
|
errors.push(...ruleValidation.errors);
|
||||||
|
warnings.push(...ruleValidation.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate rules object
|
||||||
|
*/
|
||||||
|
function validateRules(rules) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
// Check rule type
|
||||||
|
if (!rules.type) {
|
||||||
|
errors.push('Rules must have a type');
|
||||||
|
} else if (!VALID_RULE_TYPES.includes(rules.type)) {
|
||||||
|
errors.push(`Invalid rule type: ${rules.type}. Must be one of: ${VALID_RULE_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate based on rule type
|
||||||
|
switch (rules.type) {
|
||||||
|
case 'entity':
|
||||||
|
validateEntityRule(rules, errors, warnings);
|
||||||
|
break;
|
||||||
|
case 'dok':
|
||||||
|
validateDOKRule(rules, errors, warnings);
|
||||||
|
break;
|
||||||
|
case 'points':
|
||||||
|
validatePointsRule(rules, errors, warnings);
|
||||||
|
break;
|
||||||
|
case 'filtered':
|
||||||
|
validateFilteredRule(rules, errors, warnings);
|
||||||
|
break;
|
||||||
|
case 'counter':
|
||||||
|
validateCounterRule(rules, errors, warnings);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filters if present
|
||||||
|
if (rules.filters) {
|
||||||
|
const filterValidation = validateFilters(rules.filters);
|
||||||
|
errors.push(...filterValidation.errors);
|
||||||
|
warnings.push(...filterValidation.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate entity rule
|
||||||
|
*/
|
||||||
|
function validateEntityRule(rules, errors, warnings) {
|
||||||
|
if (!rules.entityType) {
|
||||||
|
errors.push('Entity rule requires entityType');
|
||||||
|
} else if (!VALID_ENTITY_TYPES.includes(rules.entityType)) {
|
||||||
|
errors.push(`Invalid entityType: ${rules.entityType}. Must be one of: ${VALID_ENTITY_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
|
||||||
|
errors.push('Entity rule requires a positive target number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.allowed_bands) {
|
||||||
|
if (!Array.isArray(rules.allowed_bands)) {
|
||||||
|
errors.push('allowed_bands must be an array');
|
||||||
|
} else {
|
||||||
|
for (const band of rules.allowed_bands) {
|
||||||
|
if (!VALID_BANDS.includes(band)) {
|
||||||
|
warnings.push(`Unknown band in allowed_bands: ${band}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.satellite_only !== undefined && typeof rules.satellite_only !== 'boolean') {
|
||||||
|
errors.push('satellite_only must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.displayField && typeof rules.displayField !== 'string') {
|
||||||
|
errors.push('displayField must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate DOK rule
|
||||||
|
*/
|
||||||
|
function validateDOKRule(rules, errors, warnings) {
|
||||||
|
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
|
||||||
|
errors.push('DOK rule requires a positive target number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.confirmationType && rules.confirmationType !== 'dcl') {
|
||||||
|
warnings.push('DOK rule confirmationType should be "dcl"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.displayField && typeof rules.displayField !== 'string') {
|
||||||
|
errors.push('displayField must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate points rule
|
||||||
|
*/
|
||||||
|
function validatePointsRule(rules, errors, warnings) {
|
||||||
|
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
|
||||||
|
errors.push('Points rule requires a positive target number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules.stations || !Array.isArray(rules.stations)) {
|
||||||
|
errors.push('Points rule requires a stations array');
|
||||||
|
} else if (rules.stations.length === 0) {
|
||||||
|
errors.push('Points rule stations array cannot be empty');
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < rules.stations.length; i++) {
|
||||||
|
const station = rules.stations[i];
|
||||||
|
if (!station.callsign || typeof station.callsign !== 'string') {
|
||||||
|
errors.push(`Station ${i + 1} missing callsign`);
|
||||||
|
}
|
||||||
|
if (typeof station.points !== 'number' || station.points <= 0) {
|
||||||
|
errors.push(`Station ${i + 1} must have positive points value`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.countMode && !VALID_COUNT_MODES.includes(rules.countMode)) {
|
||||||
|
errors.push(`Invalid countMode: ${rules.countMode}. Must be one of: ${VALID_COUNT_MODES.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate filtered rule
|
||||||
|
*/
|
||||||
|
function validateFilteredRule(rules, errors, warnings) {
|
||||||
|
if (!rules.baseRule) {
|
||||||
|
errors.push('Filtered rule requires baseRule');
|
||||||
|
} else {
|
||||||
|
// Recursively validate base rule
|
||||||
|
const baseValidation = validateRules(rules.baseRule);
|
||||||
|
errors.push(...baseValidation.errors);
|
||||||
|
warnings.push(...baseValidation.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules.filters) {
|
||||||
|
warnings.push('Filtered rule has no filters - baseRule will be used as-is');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate counter rule
|
||||||
|
*/
|
||||||
|
function validateCounterRule(rules, errors, warnings) {
|
||||||
|
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
|
||||||
|
errors.push('Counter rule requires a positive target number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules.countBy) {
|
||||||
|
errors.push('Counter rule requires countBy');
|
||||||
|
} else if (!['qso', 'callsign'].includes(rules.countBy)) {
|
||||||
|
errors.push(`Invalid countBy: ${rules.countBy}. Must be one of: qso, callsign`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.displayField && typeof rules.displayField !== 'string') {
|
||||||
|
errors.push('displayField must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate filters object
|
||||||
|
*/
|
||||||
|
function validateFilters(filters, depth = 0) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
if (!filters) {
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent infinite recursion
|
||||||
|
if (depth > 10) {
|
||||||
|
errors.push('Filters are too deeply nested (maximum 10 levels)');
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.operator && !['AND', 'OR'].includes(filters.operator)) {
|
||||||
|
errors.push(`Invalid filter operator: ${filters.operator}. Must be AND or OR`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
if (!Array.isArray(filters.filters)) {
|
||||||
|
errors.push('Filters must be an array');
|
||||||
|
} else {
|
||||||
|
for (const filter of filters.filters) {
|
||||||
|
if (filter.filters) {
|
||||||
|
// Nested filter group
|
||||||
|
const nestedValidation = validateFilters(filter, depth + 1);
|
||||||
|
errors.push(...nestedValidation.errors);
|
||||||
|
warnings.push(...nestedValidation.warnings);
|
||||||
|
} else {
|
||||||
|
// Leaf filter
|
||||||
|
if (!filter.field) {
|
||||||
|
errors.push('Filter missing field');
|
||||||
|
} else if (!VALID_FILTER_FIELDS.includes(filter.field)) {
|
||||||
|
warnings.push(`Unknown filter field: ${filter.field}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter.operator) {
|
||||||
|
errors.push('Filter missing operator');
|
||||||
|
} else if (!VALID_FILTER_OPERATORS.includes(filter.operator)) {
|
||||||
|
errors.push(`Invalid filter operator: ${filter.operator}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === undefined) {
|
||||||
|
errors.push('Filter missing value');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['in', 'nin'].includes(filter.operator) && !Array.isArray(filter.value)) {
|
||||||
|
errors.push(`Filter operator ${filter.operator} requires an array value`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new award definition
|
||||||
|
*/
|
||||||
|
export async function createAwardDefinition(definition) {
|
||||||
|
// Get all existing definitions for duplicate check
|
||||||
|
const existing = await getAllAwardDefinitions();
|
||||||
|
|
||||||
|
// Validate the definition
|
||||||
|
const validation = validateAwardDefinition(definition, existing);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filename from ID
|
||||||
|
const filename = `${definition.id}.json`;
|
||||||
|
const filepath = join(AWARD_DEFINITIONS_DIR, filename);
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
throw new Error(`Award file "${filename}" already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove metadata fields before saving
|
||||||
|
const { _filename, _filepath, ...cleanDefinition } = definition;
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// Clear the cache so new award is immediately available
|
||||||
|
clearAwardCache();
|
||||||
|
|
||||||
|
logger.info('Created award definition', { id: definition.id, filename });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cleanDefinition,
|
||||||
|
_filename: filename,
|
||||||
|
_filepath: filepath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing award definition
|
||||||
|
*/
|
||||||
|
export async function updateAwardDefinition(id, updatedDefinition) {
|
||||||
|
// Get existing definition
|
||||||
|
const existing = await getAwardDefinition(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Award "${id}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ID matches
|
||||||
|
if (updatedDefinition.id && updatedDefinition.id !== id) {
|
||||||
|
throw new Error('Cannot change award ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the ID from the parameter
|
||||||
|
updatedDefinition.id = id;
|
||||||
|
|
||||||
|
// Get all definitions for validation
|
||||||
|
const allDefinitions = await getAllAwardDefinitions();
|
||||||
|
|
||||||
|
// Validate the updated definition
|
||||||
|
const validation = validateAwardDefinition(updatedDefinition, allDefinitions);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the same filename
|
||||||
|
const filename = existing._filename;
|
||||||
|
const filepath = existing._filepath;
|
||||||
|
|
||||||
|
// Remove metadata fields before saving
|
||||||
|
const { _filename, _filepath, ...cleanDefinition } = updatedDefinition;
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// Clear the cache so updated award is immediately available
|
||||||
|
clearAwardCache();
|
||||||
|
|
||||||
|
logger.info('Updated award definition', { id, filename });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cleanDefinition,
|
||||||
|
_filename: filename,
|
||||||
|
_filepath: filepath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an award definition
|
||||||
|
*/
|
||||||
|
export async function deleteAwardDefinition(id) {
|
||||||
|
const existing = await getAwardDefinition(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Award "${id}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
unlinkSync(existing._filepath);
|
||||||
|
|
||||||
|
// Clear the cache so deleted award is immediately removed
|
||||||
|
clearAwardCache();
|
||||||
|
|
||||||
|
logger.info('Deleted award definition', { id, filename: existing._filename });
|
||||||
|
|
||||||
|
return { success: true, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test award calculation for a user
|
||||||
|
* @param {string} id - Award ID (must exist unless awardDefinition is provided)
|
||||||
|
* @param {number} userId - User ID to test with
|
||||||
|
* @param {Object} awardDefinition - Optional award definition (for testing unsaved awards)
|
||||||
|
*/
|
||||||
|
export async function testAwardCalculation(id, userId, awardDefinition = null) {
|
||||||
|
// Get award definition - either from parameter or from cache
|
||||||
|
let award = awardDefinition;
|
||||||
|
if (!award) {
|
||||||
|
award = getAwardById(id);
|
||||||
|
if (!award) {
|
||||||
|
throw new Error(`Award "${id}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress
|
||||||
|
const progress = await calculateAwardProgress(userId, award);
|
||||||
|
|
||||||
|
// Warn if no matches
|
||||||
|
const warnings = [];
|
||||||
|
if (progress.worked === 0 && progress.confirmed === 0) {
|
||||||
|
warnings.push('No QSOs matched the award criteria. Check filters and band/mode restrictions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sample entities
|
||||||
|
const sampleEntities = (progress.confirmedEntities || []).slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
award: {
|
||||||
|
id: award.id,
|
||||||
|
name: award.name,
|
||||||
|
description: award.description,
|
||||||
|
},
|
||||||
|
worked: progress.worked,
|
||||||
|
confirmed: progress.confirmed,
|
||||||
|
target: progress.target,
|
||||||
|
percentage: progress.percentage,
|
||||||
|
sampleEntities,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,8 +30,7 @@ function loadAwardDefinitions() {
|
|||||||
try {
|
try {
|
||||||
// Auto-discover all JSON files in the award-definitions directory
|
// Auto-discover all JSON files in the award-definitions directory
|
||||||
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
||||||
.filter(f => f.endsWith('.json'))
|
.filter(f => f.endsWith('.json'));
|
||||||
.sort();
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
@@ -47,12 +46,103 @@ function loadAwardDefinitions() {
|
|||||||
logger.error('Error loading award definitions', { error: error.message });
|
logger.error('Error loading award definitions', { error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by award name with numeric prefixes in numerical order
|
||||||
|
definitions.sort((a, b) => {
|
||||||
|
const nameA = a.name || '';
|
||||||
|
const nameB = b.name || '';
|
||||||
|
|
||||||
|
// Extract leading numbers if present
|
||||||
|
const matchA = nameA.match(/^(\d+)/);
|
||||||
|
const matchB = nameB.match(/^(\d+)/);
|
||||||
|
|
||||||
|
// If both start with numbers, compare numerically first
|
||||||
|
if (matchA && matchB) {
|
||||||
|
const numA = parseInt(matchA[1], 10);
|
||||||
|
const numB = parseInt(matchB[1], 10);
|
||||||
|
if (numA !== numB) {
|
||||||
|
return numA - numB;
|
||||||
|
}
|
||||||
|
// If numbers are equal, fall through to alphabetical
|
||||||
|
}
|
||||||
|
// If one starts with a number, it comes first
|
||||||
|
else if (matchA) return -1;
|
||||||
|
else if (matchB) return 1;
|
||||||
|
|
||||||
|
// Otherwise, alphabetical comparison (case-insensitive)
|
||||||
|
return nameA.toLowerCase().localeCompare(nameB.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
// Cache the definitions for future calls
|
// Cache the definitions for future calls
|
||||||
cachedAwardDefinitions = definitions;
|
cachedAwardDefinitions = definitions;
|
||||||
|
|
||||||
return definitions;
|
return definitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached award definitions
|
||||||
|
* Call this after creating, updating, or deleting award definitions
|
||||||
|
*/
|
||||||
|
export function clearAwardCache() {
|
||||||
|
cachedAwardDefinitions = null;
|
||||||
|
cachedWAECountryList = null;
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
@@ -67,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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,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()
|
||||||
@@ -200,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,
|
||||||
@@ -208,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,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
|
||||||
*/
|
*/
|
||||||
@@ -742,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()
|
||||||
@@ -787,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)) {
|
||||||
|
|||||||
@@ -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
186
src/frontend/src/app.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
@@ -130,3 +130,29 @@ export const autoSyncAPI = {
|
|||||||
|
|
||||||
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
|
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Awards Admin API
|
||||||
|
export const awardsAdminAPI = {
|
||||||
|
getAll: () => apiRequest('/admin/awards'),
|
||||||
|
|
||||||
|
getById: (id) => apiRequest(`/admin/awards/${id}`),
|
||||||
|
|
||||||
|
create: (data) => apiRequest('/admin/awards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (id, data) => apiRequest(`/admin/awards/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id) => apiRequest(`/admin/awards/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId, awardDefinition }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
152
src/frontend/src/lib/components/ThemeSwitcher.svelte
Normal file
152
src/frontend/src/lib/components/ThemeSwitcher.svelte
Normal 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>
|
||||||
56
src/frontend/src/lib/stores/theme.js
Normal file
56
src/frontend/src/lib/stores/theme.js
Normal 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();
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
let impersonationStatus = null;
|
let impersonationStatus = null;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let selectedTab = 'overview'; // 'overview', 'users', 'actions'
|
let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
|
||||||
let showImpersonationModal = false;
|
let showImpersonationModal = false;
|
||||||
let showDeleteUserModal = false;
|
let showDeleteUserModal = false;
|
||||||
let showRoleChangeModal = false;
|
let showRoleChangeModal = false;
|
||||||
@@ -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;
|
||||||
@@ -226,6 +227,12 @@
|
|||||||
>
|
>
|
||||||
Users
|
Users
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {selectedTab === 'awards' ? 'active' : ''}"
|
||||||
|
on:click={() => selectedTab = 'awards'}
|
||||||
|
>
|
||||||
|
Awards
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
||||||
on:click={() => selectedTab = 'actions'}
|
on:click={() => selectedTab = 'actions'}
|
||||||
@@ -311,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>
|
||||||
@@ -330,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>
|
||||||
@@ -340,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>
|
||||||
@@ -349,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>
|
||||||
@@ -382,6 +392,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Awards Tab -->
|
||||||
|
{#if selectedTab === 'awards'}
|
||||||
|
<div class="tab-content">
|
||||||
|
<h2>Award Definitions</h2>
|
||||||
|
<p class="help-text">Manage award definitions. Create, edit, and delete awards.</p>
|
||||||
|
|
||||||
|
<div class="awards-quick-actions">
|
||||||
|
<a href="/admin/awards" class="btn btn-primary">Manage Awards</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="awards-info">
|
||||||
|
<h3>Award Management</h3>
|
||||||
|
<p>From the Awards management page, you can:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Create</strong> new award definitions</li>
|
||||||
|
<li><strong>Edit</strong> existing award definitions</li>
|
||||||
|
<li><strong>Delete</strong> awards</li>
|
||||||
|
<li><strong>Test</strong> award calculations with sample user data</li>
|
||||||
|
</ul>
|
||||||
|
<p>All award definitions are stored as JSON files in the <code>award-definitions/</code> directory.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Actions Tab -->
|
<!-- Actions Tab -->
|
||||||
{#if selectedTab === 'actions'}
|
{#if selectedTab === 'actions'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -463,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>
|
||||||
@@ -518,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,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 */
|
||||||
@@ -546,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 {
|
||||||
@@ -556,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 */
|
||||||
@@ -594,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 {
|
||||||
@@ -638,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 {
|
||||||
@@ -655,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,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 {
|
||||||
@@ -691,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;
|
||||||
@@ -712,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 {
|
||||||
@@ -741,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,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 {
|
||||||
@@ -788,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,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;
|
||||||
@@ -819,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 */
|
||||||
@@ -837,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 {
|
||||||
@@ -873,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 {
|
||||||
@@ -919,6 +970,50 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-quick-actions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info ul {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info code {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.users-header {
|
.users-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
381
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
381
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { auth } from '$lib/stores.js';
|
||||||
|
import { awardsAdminAPI } from '$lib/api.js';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = null;
|
||||||
|
let awards = [];
|
||||||
|
let searchQuery = '';
|
||||||
|
let categoryFilter = 'all';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$auth.user) {
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$auth.user.isAdmin) {
|
||||||
|
error = 'Admin access required';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAwards();
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAwards() {
|
||||||
|
try {
|
||||||
|
const data = await awardsAdminAPI.getAll();
|
||||||
|
awards = data.awards || [];
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
const award = awards.find(a => a.id === id);
|
||||||
|
if (!award) return;
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete award "${award.name}"?\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
await awardsAdminAPI.delete(id);
|
||||||
|
await loadAwards();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete award: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleTypeDisplayName(ruleType) {
|
||||||
|
const names = {
|
||||||
|
'entity': 'Entity',
|
||||||
|
'dok': 'DOK',
|
||||||
|
'points': 'Points',
|
||||||
|
'filtered': 'Filtered',
|
||||||
|
'counter': 'Counter',
|
||||||
|
'wae': 'WAE'
|
||||||
|
};
|
||||||
|
return names[ruleType] || ruleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryColor(category) {
|
||||||
|
const colors = {
|
||||||
|
'dxcc': 'purple',
|
||||||
|
'darc': 'orange',
|
||||||
|
'vucc': 'blue',
|
||||||
|
'was': 'green',
|
||||||
|
'special': 'red',
|
||||||
|
};
|
||||||
|
return colors[category] || 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filteredAwards = awards.filter(award => {
|
||||||
|
const matchesSearch = !searchQuery ||
|
||||||
|
award.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
award.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
award.category.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesCategory = categoryFilter === 'all' || award.category === categoryFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
$: categories = [...new Set(awards.map(a => a.category))].sort();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading && awards.length === 0}
|
||||||
|
<div class="loading">Loading award definitions...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="awards-admin">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Award Definitions</h1>
|
||||||
|
<a href="/admin/awards/create" class="btn btn-primary">Create New Award</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search by name, ID, or category..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
<select class="category-filter" bind:value={categoryFilter}>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category}>{category}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="awards-table-container">
|
||||||
|
<table class="awards-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Rule Type</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredAwards as award}
|
||||||
|
<tr>
|
||||||
|
<td class="id-cell">{award.id}</td>
|
||||||
|
<td>
|
||||||
|
<div class="name-cell">
|
||||||
|
<strong>{award.name}</strong>
|
||||||
|
<small>{award.description}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="category-badge {getCategoryColor(award.category)}">
|
||||||
|
{award.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{getRuleTypeDisplayName(award.rules.type)}</td>
|
||||||
|
<td>{award.rules.target || '-'}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a href="/admin/awards/{award.id}" class="action-btn edit-btn">Edit</a>
|
||||||
|
<a href="/awards/{award.id}" target="_blank" class="action-btn view-btn">View</a>
|
||||||
|
<button
|
||||||
|
class="action-btn delete-btn"
|
||||||
|
on:click={() => handleDelete(award.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="count">Showing {filteredAwards.length} award(s)</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.awards-admin {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: var(--color-error-bg);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-error);
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.category-filter {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-filter {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--text-inverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table th,
|
||||||
|
.awards-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table th {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table tr:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge.purple { background-color: #9b59b6; color: white; }
|
||||||
|
.category-badge.orange { background-color: #e67e22; color: white; }
|
||||||
|
.category-badge.blue { background-color: #3498db; color: white; }
|
||||||
|
.category-badge.green { background-color: #27ae60; color: white; }
|
||||||
|
.category-badge.red { background-color: #e74c3c; color: white; }
|
||||||
|
.category-badge.gray { background-color: #95a5a6; color: white; }
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-inverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
background-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
background-color: #219a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.awards-admin {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1516
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
1516
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let filters = null;
|
||||||
|
export let onChange = () => {};
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
const ALL_FIELDS = [
|
||||||
|
{ value: 'band', label: 'Band' },
|
||||||
|
{ value: 'mode', label: 'Mode' },
|
||||||
|
{ value: 'callsign', label: 'Callsign' },
|
||||||
|
{ value: 'entity', label: 'Entity (Country)' },
|
||||||
|
{ value: 'entityId', label: 'Entity ID' },
|
||||||
|
{ value: 'state', label: 'State' },
|
||||||
|
{ value: 'grid', label: 'Grid Square' },
|
||||||
|
{ value: 'satName', label: 'Satellite Name' },
|
||||||
|
{ value: 'satellite', label: 'Is Satellite QSO' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPERATORS = [
|
||||||
|
{ value: 'eq', label: 'Equals', needsArray: false },
|
||||||
|
{ value: 'ne', label: 'Not Equals', needsArray: false },
|
||||||
|
{ value: 'in', label: 'In', needsArray: true },
|
||||||
|
{ value: 'nin', label: 'Not In', needsArray: true },
|
||||||
|
{ value: 'contains', label: 'Contains', needsArray: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BAND_OPTIONS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
|
||||||
|
const MODE_OPTIONS = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
|
||||||
|
|
||||||
|
// Add a new filter
|
||||||
|
function addFilter() {
|
||||||
|
if (!filters) {
|
||||||
|
filters = { operator: 'AND', filters: [] };
|
||||||
|
}
|
||||||
|
if (!filters.filters) {
|
||||||
|
filters.filters = [];
|
||||||
|
}
|
||||||
|
filters.filters.push({
|
||||||
|
field: 'band',
|
||||||
|
operator: 'eq',
|
||||||
|
value: ''
|
||||||
|
});
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a nested filter group
|
||||||
|
function addFilterGroup() {
|
||||||
|
if (!filters) {
|
||||||
|
filters = { operator: 'AND', filters: [] };
|
||||||
|
}
|
||||||
|
if (!filters.filters) {
|
||||||
|
filters.filters = [];
|
||||||
|
}
|
||||||
|
filters.filters.push({
|
||||||
|
operator: 'AND',
|
||||||
|
filters: []
|
||||||
|
});
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a filter at index
|
||||||
|
function removeFilter(index) {
|
||||||
|
if (filters && filters.filters) {
|
||||||
|
filters.filters.splice(index, 1);
|
||||||
|
// If no filters left, set to null
|
||||||
|
if (filters.filters.length === 0) {
|
||||||
|
filters = null;
|
||||||
|
}
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a filter at index
|
||||||
|
function updateFilter(index, key, value) {
|
||||||
|
if (filters && filters.filters) {
|
||||||
|
filters.filters[index][key] = value;
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filter operator (AND/OR)
|
||||||
|
function updateOperator(operator) {
|
||||||
|
if (filters) {
|
||||||
|
filters.operator = operator;
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get input type based on field and operator
|
||||||
|
function getInputType(field, operator) {
|
||||||
|
const opConfig = OPERATORS.find(o => o.value === operator);
|
||||||
|
const needsArray = opConfig?.needsArray || false;
|
||||||
|
|
||||||
|
if (field === 'band' && needsArray) return 'band-multi';
|
||||||
|
if (field === 'band') return 'band';
|
||||||
|
if (field === 'mode' && needsArray) return 'mode-multi';
|
||||||
|
if (field === 'mode') return 'mode';
|
||||||
|
if (field === 'satellite') return 'boolean';
|
||||||
|
if (needsArray) return 'text-array';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
function updateFilters() {
|
||||||
|
// Deep clone to avoid reactivity issues
|
||||||
|
const cloned = filters ? JSON.parse(JSON.stringify(filters)) : null;
|
||||||
|
onChange(cloned);
|
||||||
|
dispatch('change', cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a filter is a group (has nested filters)
|
||||||
|
function isFilterGroup(filter) {
|
||||||
|
return filter && typeof filter === 'object' && filter.filters !== undefined;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="filter-builder">
|
||||||
|
{#if !filters || !filters.filters || filters.filters.length === 0}
|
||||||
|
<div class="no-filters">
|
||||||
|
<p>No filters defined. All QSOs will be evaluated.</p>
|
||||||
|
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="filter-group">
|
||||||
|
<div class="filter-group-header">
|
||||||
|
<h4>Filters</h4>
|
||||||
|
<div class="operator-selector">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={filters.operator}
|
||||||
|
value="AND"
|
||||||
|
on:change={() => updateOperator('AND')}
|
||||||
|
/>
|
||||||
|
AND
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={filters.operator}
|
||||||
|
value="OR"
|
||||||
|
on:change={() => updateOperator('OR')}
|
||||||
|
/>
|
||||||
|
OR
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-list">
|
||||||
|
{#each filters.filters as filter, index}
|
||||||
|
<div class="filter-item">
|
||||||
|
{#if isFilterGroup(filter)}
|
||||||
|
<!-- Nested filter group -->
|
||||||
|
<div class="nested-filter-group">
|
||||||
|
<div class="nested-header">
|
||||||
|
<span class="group-label">Group ({filter.operator})</span>
|
||||||
|
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
<svelte:self filters={filter} onChange={(nested) => updateFilter(index, nested)} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Single filter -->
|
||||||
|
<div class="single-filter">
|
||||||
|
<select
|
||||||
|
class="field-select"
|
||||||
|
bind:value={filter.field}
|
||||||
|
on:change={() => updateFilter(index, 'field', filter.field)}
|
||||||
|
>
|
||||||
|
{#each ALL_FIELDS as field}
|
||||||
|
<option value={field.value}>{field.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="operator-select"
|
||||||
|
bind:value={filter.operator}
|
||||||
|
on:change={() => updateFilter(index, 'operator', filter.operator)}
|
||||||
|
>
|
||||||
|
{#each OPERATORS as op}
|
||||||
|
<option value={op.value}>{op.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="filter-value">
|
||||||
|
{#if getInputType(filter.field, filter.operator) === 'band'}
|
||||||
|
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
|
||||||
|
<option value="">Select band</option>
|
||||||
|
{#each BAND_OPTIONS as band}
|
||||||
|
<option value={band}>{band}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'band-multi'}
|
||||||
|
<div class="multi-select">
|
||||||
|
{#each BAND_OPTIONS as band}
|
||||||
|
<label class="checkbox-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Array.isArray(filter.value) && filter.value.includes(band)}
|
||||||
|
on:change={(e) => {
|
||||||
|
if (!Array.isArray(filter.value)) filter.value = [];
|
||||||
|
if (e.target.checked) {
|
||||||
|
filter.value = [...filter.value, band];
|
||||||
|
} else {
|
||||||
|
filter.value = filter.value.filter(v => v !== band);
|
||||||
|
}
|
||||||
|
updateFilter(index, 'value', filter.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{band}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'mode'}
|
||||||
|
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
|
||||||
|
<option value="">Select mode</option>
|
||||||
|
{#each MODE_OPTIONS as mode}
|
||||||
|
<option value={mode}>{mode}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'mode-multi'}
|
||||||
|
<div class="multi-select">
|
||||||
|
{#each MODE_OPTIONS as mode}
|
||||||
|
<label class="checkbox-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Array.isArray(filter.value) && filter.value.includes(mode)}
|
||||||
|
on:change={(e) => {
|
||||||
|
if (!Array.isArray(filter.value)) filter.value = [];
|
||||||
|
if (e.target.checked) {
|
||||||
|
filter.value = [...filter.value, mode];
|
||||||
|
} else {
|
||||||
|
filter.value = filter.value.filter(v => v !== mode);
|
||||||
|
}
|
||||||
|
updateFilter(index, 'value', filter.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{mode}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'boolean'}
|
||||||
|
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
|
||||||
|
<option value="true">Yes</option>
|
||||||
|
<option value="false">No</option>
|
||||||
|
</select>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'text-array'}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="comma-separated values"
|
||||||
|
value={Array.isArray(filter.value) ? filter.value.join(', ') : filter.value}
|
||||||
|
on:change={(e) => {
|
||||||
|
const values = e.target.value.split(',').map(v => v.trim()).filter(v => v);
|
||||||
|
updateFilter(index, 'value', values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
bind:value={filter.value}
|
||||||
|
on:change={() => updateFilter(index, 'value', filter.value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
|
||||||
|
<button class="btn btn-secondary" on:click={addFilterGroup}>Add Filter Group</button>
|
||||||
|
<button class="btn btn-danger" on:click={() => { filters = null; updateFilters(); }}>Clear All Filters</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filter-builder {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-filters {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-selector label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-filter select,
|
||||||
|
.single-filter input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-select {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-select {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-value select,
|
||||||
|
.filter-value input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested-filter-group {
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-option input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: var(--text-inverted);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-inverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.single-filter {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-select,
|
||||||
|
.operator-select,
|
||||||
|
.filter-value {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { awardsAdminAPI, adminAPI } from '$lib/api.js';
|
||||||
|
|
||||||
|
export let awardId = null;
|
||||||
|
export let awardDefinition = null;
|
||||||
|
export let onClose = () => {};
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
let testResult = null;
|
||||||
|
let testError = null;
|
||||||
|
let users = [];
|
||||||
|
let selectedUserId = null;
|
||||||
|
|
||||||
|
// Extended validation results
|
||||||
|
let logicValidation = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadUsers();
|
||||||
|
if (awardDefinition) {
|
||||||
|
performLogicValidation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const data = await adminAPI.getUsers();
|
||||||
|
users = data.users || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load users:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
if (!awardId) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
testResult = null;
|
||||||
|
testError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass awardDefinition for unsaved awards (testing during create/edit)
|
||||||
|
const data = await awardsAdminAPI.test(awardId, selectedUserId, awardDefinition);
|
||||||
|
testResult = data;
|
||||||
|
} catch (err) {
|
||||||
|
testError = err.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform deep logic validation on award definition
|
||||||
|
function performLogicValidation() {
|
||||||
|
if (!awardDefinition) return;
|
||||||
|
|
||||||
|
const issues = {
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
info: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = awardDefinition.rules;
|
||||||
|
|
||||||
|
// 1. Check for impossible filter combinations
|
||||||
|
if (rules.filters) {
|
||||||
|
const impossibleFilterCombos = checkImpossibleFilters(rules.filters, rules);
|
||||||
|
issues.errors.push(...impossibleFilterCombos.errors);
|
||||||
|
issues.warnings.push(...impossibleFilterCombos.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for redundancy
|
||||||
|
const redundancies = checkRedundancies(rules);
|
||||||
|
issues.warnings.push(...redundancies.warnings);
|
||||||
|
issues.info.push(...redundancies.info);
|
||||||
|
|
||||||
|
// 3. Check for logical contradictions
|
||||||
|
const contradictions = checkContradictions(rules);
|
||||||
|
issues.errors.push(...contradictions.errors);
|
||||||
|
issues.warnings.push(...contradictions.warnings);
|
||||||
|
|
||||||
|
// 4. Check for edge cases that might cause issues
|
||||||
|
const edgeCases = checkEdgeCases(rules);
|
||||||
|
issues.info.push(...edgeCases);
|
||||||
|
|
||||||
|
// 5. Provide helpful suggestions
|
||||||
|
const suggestions = provideSuggestions(rules);
|
||||||
|
issues.info.push(...suggestions);
|
||||||
|
|
||||||
|
logicValidation = issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for impossible filter combinations
|
||||||
|
function checkImpossibleFilters(filters, rules) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
function analyze(filterNode, depth = 0) {
|
||||||
|
if (!filterNode || !filterNode.filters) return;
|
||||||
|
|
||||||
|
// Group filters by field to check for contradictions
|
||||||
|
const fieldFilters = {};
|
||||||
|
for (const f of filterNode.filters) {
|
||||||
|
if (f.field) {
|
||||||
|
if (!fieldFilters[f.field]) fieldFilters[f.field] = [];
|
||||||
|
fieldFilters[f.field].push(f);
|
||||||
|
} else if (f.filters) {
|
||||||
|
analyze(f, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contradictions in AND groups
|
||||||
|
if (filterNode.operator === 'AND') {
|
||||||
|
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
|
||||||
|
// Check for direct contradictions: field=X AND field=Y
|
||||||
|
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
|
||||||
|
const neFilters = fieldFiltersList.filter(f => f.operator === 'ne');
|
||||||
|
|
||||||
|
for (const eq1 of eqFilters) {
|
||||||
|
for (const eq2 of eqFilters) {
|
||||||
|
if (eq1 !== eq2 && eq1.value !== eq2.value) {
|
||||||
|
errors.push(`Impossible filter: ${field} cannot be both "${eq1.value}" AND "${eq2.value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ne of neFilters) {
|
||||||
|
if (eq1.value === ne.value) {
|
||||||
|
errors.push(`Impossible filter: ${field} cannot be "${eq1.value}" AND not "${ne.value}" at the same time`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for in/nin contradictions
|
||||||
|
const inFilters = fieldFiltersList.filter(f => f.operator === 'in');
|
||||||
|
const ninFilters = fieldFiltersList.filter(f => f.operator === 'nin');
|
||||||
|
|
||||||
|
for (const inF of inFilters) {
|
||||||
|
if (Array.isArray(inF.value)) {
|
||||||
|
for (const ninF of ninFilters) {
|
||||||
|
if (Array.isArray(ninF.value)) {
|
||||||
|
const overlap = inF.value.filter(v => ninF.value.includes(v));
|
||||||
|
if (overlap.length > 0 && overlap.length === inF.value.length) {
|
||||||
|
errors.push(`Impossible filter: ${field} must be in ${inF.value.join(', ')} AND not in ${overlap.join(', ')}`);
|
||||||
|
} else if (overlap.length > 0) {
|
||||||
|
warnings.push(`Suspicious filter: ${field} filter has overlapping values: ${overlap.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for redundant OR groups (field=X OR field=X)
|
||||||
|
if (filterNode.operator === 'OR') {
|
||||||
|
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
|
||||||
|
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
|
||||||
|
|
||||||
|
for (let i = 0; i < eqFilters.length; i++) {
|
||||||
|
for (let j = i + 1; j < eqFilters.length; j++) {
|
||||||
|
if (eqFilters[i].value === eqFilters[j].value) {
|
||||||
|
warnings.push(`Redundant filter: ${field}="${eqFilters[i].value}" appears multiple times in OR group`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze(filters);
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for redundancies in the definition
|
||||||
|
function checkRedundancies(rules) {
|
||||||
|
const warnings = [];
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
// Check if satellite_only is redundant when filters already check for satellite
|
||||||
|
if (rules.satellite_only && rules.filters) {
|
||||||
|
const satFilter = findSatelliteFilter(rules.filters);
|
||||||
|
if (satFilter && satFilter.operator === 'eq' && satFilter.value === true) {
|
||||||
|
info.push('satellite_only=true is set, but filters already check for satellite QSOs. The filter is redundant.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if allowed_bands makes filters redundant
|
||||||
|
if (rules.allowed_bands && rules.allowed_bands.length > 0 && rules.filters) {
|
||||||
|
const bandFilters = extractBandFilters(rules.filters);
|
||||||
|
for (const bf of bandFilters) {
|
||||||
|
if (bf.operator === 'in' && Array.isArray(bf.value)) {
|
||||||
|
const allCovered = bf.value.every(b => rules.allowed_bands.includes(b));
|
||||||
|
if (allCovered) {
|
||||||
|
info.push(`allowed_bands already includes all bands in the filter. Consider removing the filter.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if displayField matches the default for the entity type
|
||||||
|
if (rules.entityType) {
|
||||||
|
const defaults = {
|
||||||
|
'dxcc': 'entity',
|
||||||
|
'state': 'state',
|
||||||
|
'grid': 'grid',
|
||||||
|
'callsign': 'callsign'
|
||||||
|
};
|
||||||
|
const defaultField = defaults[rules.entityType];
|
||||||
|
|
||||||
|
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}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { warnings, info };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for logical contradictions
|
||||||
|
function checkContradictions(rules) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
// Check satellite_only with HF-only allowed_bands
|
||||||
|
if (rules.satellite_only && rules.allowed_bands) {
|
||||||
|
const hfBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
|
||||||
|
const hasHfOnly = rules.allowed_bands.length > 0 &&
|
||||||
|
rules.allowed_bands.every(b => hfBands.includes(b));
|
||||||
|
|
||||||
|
if (hasHfOnly) {
|
||||||
|
warnings.push('satellite_only is set but allowed_bands only includes HF bands. Satellite work typically uses VHF/UHF bands.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DOK rules, verify confirmation type
|
||||||
|
if (rules.type === 'dok' && rules.confirmationType && rules.confirmationType !== 'dcl') {
|
||||||
|
warnings.push('DOK awards typically require DCL confirmation (confirmationType="dcl").');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for impossible targets
|
||||||
|
if (rules.target) {
|
||||||
|
if (rules.type === 'entity' && rules.entityType === 'dxcc' && rules.target > 340) {
|
||||||
|
warnings.push(`Target (${rules.target}) exceeds the total number of DXCC entities (~340).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'dok' && rules.target > 700) {
|
||||||
|
info.push(`Target (${rules.target}) is high. There are ~700 DOKs in Germany.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for edge cases
|
||||||
|
function checkEdgeCases(rules) {
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
if (rules.filters) {
|
||||||
|
const filterCount = countFilters(rules.filters);
|
||||||
|
if (filterCount > 10) {
|
||||||
|
info.push(`Complex filter structure (${filterCount} filters). Consider simplifying for better performance.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.modeGroups) {
|
||||||
|
const totalModes = Object.values(rules.modeGroups).reduce((sum, modes) => sum + (modes?.length || 0), 0);
|
||||||
|
if (totalModes > 20) {
|
||||||
|
info.push('Many mode groups defined. Make sure users understand the grouping logic.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'points' && rules.stations) {
|
||||||
|
const totalPossiblePoints = rules.stations.reduce((sum, s) => sum + (s.points || 0), 0);
|
||||||
|
if (totalPossiblePoints < rules.target) {
|
||||||
|
info.push(`Even with all stations confirmed, max points (${totalPossiblePoints}) is less than target (${rules.target}). Award is impossible to complete.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide helpful suggestions
|
||||||
|
function provideSuggestions(rules) {
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
// Suggest common award patterns
|
||||||
|
if (rules.type === 'entity' && rules.entityType === 'dxcc' && !rules.allowed_bands) {
|
||||||
|
info.push('Consider adding allowed_bands to restrict to specific bands (e.g., HF only: ["160m", "80m", ...]).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'entity' && !rules.modeGroups && ['dxcc', 'dld'].includes(rules.entityType)) {
|
||||||
|
info.push('Consider adding modeGroups to help users filter by mode type (e.g., "Digi-Modes", "Phone-Modes").');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'dok' && !rules.filters) {
|
||||||
|
info.push('DOK awards can have band/mode filters via the filters property. Consider adding them for specific variations.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Find satellite-related filter
|
||||||
|
function findSatelliteFilter(filters, depth = 0) {
|
||||||
|
if (!filters || depth > 5) return null;
|
||||||
|
|
||||||
|
if (filters.field === 'satellite' || filters.field === 'satName') {
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
for (const f of filters.filters) {
|
||||||
|
const found = findSatelliteFilter(f, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract band filters
|
||||||
|
function extractBandFilters(filters, depth = 0) {
|
||||||
|
if (!filters || depth > 5) return [];
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if (filters.field === 'band') {
|
||||||
|
result.push(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
for (const f of filters.filters) {
|
||||||
|
result.push(...extractBandFilters(f, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Count total filters
|
||||||
|
function countFilters(filters, depth = 0) {
|
||||||
|
if (!filters || depth > 5) return 0;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
for (const f of filters.filters) {
|
||||||
|
if (f.filters) {
|
||||||
|
count += 1 + countFilters(f, depth + 1);
|
||||||
|
} else {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeverityClass(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'error': return 'severity-error';
|
||||||
|
case 'warning': return 'severity-warning';
|
||||||
|
case 'info': return 'severity-info';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if logicValidation || testResult || testError}
|
||||||
|
<div class="modal-overlay" on:click={onClose}>
|
||||||
|
<div class="modal-content large" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{testResult ? 'Test Results' : 'Award Validation'}{awardId ? `: ${awardId}` : ''}</h2>
|
||||||
|
<button class="close-btn" on:click={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Logic Validation Section -->
|
||||||
|
{#if logicValidation && (logicValidation.errors.length > 0 || logicValidation.warnings.length > 0 || logicValidation.info.length > 0)}
|
||||||
|
<div class="validation-section">
|
||||||
|
<h3>Logic Validation</h3>
|
||||||
|
|
||||||
|
{#if logicValidation.errors.length > 0}
|
||||||
|
<div class="validation-block errors">
|
||||||
|
<h4>Errors (must fix)</h4>
|
||||||
|
<ul>
|
||||||
|
{#each logicValidation.errors as err}
|
||||||
|
<li class="severity-error">{err}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if logicValidation.warnings.length > 0}
|
||||||
|
<div class="validation-block warnings">
|
||||||
|
<h4>Warnings</h4>
|
||||||
|
<ul>
|
||||||
|
{#each logicValidation.warnings as warn}
|
||||||
|
<li class="severity-warning">{warn}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if logicValidation.info.length > 0}
|
||||||
|
<div class="validation-block info">
|
||||||
|
<h4>Suggestions</h4>
|
||||||
|
<ul>
|
||||||
|
{#each logicValidation.info as info}
|
||||||
|
<li class="severity-info">{info}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if logicValidation.errors.length === 0 && logicValidation.warnings.length === 0}
|
||||||
|
<div class="validation-block success">
|
||||||
|
<p>No issues found. The award definition looks good!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Test Configuration -->
|
||||||
|
<div class="test-config">
|
||||||
|
<h3>Test Calculation</h3>
|
||||||
|
<p class="help-text">Select a user to test the award calculation with their QSO data.</p>
|
||||||
|
|
||||||
|
<div class="user-selector">
|
||||||
|
<label for="test-user">Test with user:</label>
|
||||||
|
<select id="test-user" bind:value={selectedUserId}>
|
||||||
|
<option value="">-- Select a user --</option>
|
||||||
|
{#each users as user}
|
||||||
|
<option value={user.id}>{user.callsign} ({user.email}) - {user.qsoCount || 0} QSOs</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={runTest}
|
||||||
|
disabled={loading || !selectedUserId || !awardId}
|
||||||
|
>
|
||||||
|
{loading ? 'Testing...' : 'Run Test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Results -->
|
||||||
|
{#if testError}
|
||||||
|
<div class="test-results error">
|
||||||
|
<h4>Test Failed</h4>
|
||||||
|
<p>{testError}</p>
|
||||||
|
</div>
|
||||||
|
{:else if testResult}
|
||||||
|
<div class="test-results success">
|
||||||
|
<h4>Test Results</h4>
|
||||||
|
|
||||||
|
<div class="result-summary">
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Award:</span>
|
||||||
|
<span class="value">{testResult.award?.name || awardId}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Worked:</span>
|
||||||
|
<span class="value">{testResult.worked || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Confirmed:</span>
|
||||||
|
<span class="value confirmed">{testResult.confirmed || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Target:</span>
|
||||||
|
<span class="value">{testResult.target || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Progress:</span>
|
||||||
|
<span class="value progress">{testResult.percentage || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if testResult.warnings && testResult.warnings.length > 0}
|
||||||
|
<div class="result-warnings">
|
||||||
|
<h5>Warnings:</h5>
|
||||||
|
<ul>
|
||||||
|
{#each testResult.warnings as warning}
|
||||||
|
<li>{warning}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if testResult.sampleEntities && testResult.sampleEntities.length > 0}
|
||||||
|
<div class="sample-entities">
|
||||||
|
<h5>Sample Matched Entities (first {testResult.sampleEntities.length}):</h5>
|
||||||
|
<div class="entities-list">
|
||||||
|
{#each testResult.sampleEntities as entity}
|
||||||
|
<span class="entity-tag">{entity}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-matches">
|
||||||
|
<p>No entities matched. Check filters and band/mode restrictions.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" on:click={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content.large {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.errors {
|
||||||
|
background-color: var(--color-error-bg);
|
||||||
|
border-left: 4px solid var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.warnings {
|
||||||
|
background-color: var(--color-warning-bg);
|
||||||
|
border-left: 4px solid var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.info {
|
||||||
|
background-color: var(--color-info-bg);
|
||||||
|
border-left: 4px solid var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.success {
|
||||||
|
background-color: var(--color-success-bg);
|
||||||
|
border-left: 4px solid var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-info {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results.error {
|
||||||
|
background-color: var(--color-error-bg);
|
||||||
|
border-left: 4px solid var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results.success {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results h4 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .value.confirmed {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .value.progress {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-warnings {
|
||||||
|
background-color: var(--color-warning-bg);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-warnings h5 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-warnings ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-entities {
|
||||||
|
background-color: var(--color-info-bg);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-entities h5 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entities-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-tag {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-matches {
|
||||||
|
background-color: var(--color-warning-bg);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-inverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.modal-content {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1556
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
1556
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
1
start.sh
1
start.sh
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user