Compare commits

...

20 Commits

Author SHA1 Message Date
ebdd75e03f fix: invalidate caches after deleting QSOs
After deleting all QSOs, invalidate the stats and user caches so the
QSO page shows updated statistics instead of stale cached data.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:28:37 +01:00
205b311244 fix: handle foreign key constraints when deleting QSOs
The qso_changes table has a foreign key reference to qsos.id, which
was preventing QSO deletion. Now deletes related qso_changes records
first before deleting QSOs.

Also added better error logging to the DELETE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:26:43 +01:00
6bc0a2f9b2 fix: return correct count from deleteQSOs function
The db.delete() returns a result object with a 'changes' property
indicating the number of affected rows, not the count directly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:22:13 +01:00
8550b91255 feat: add DXCC SAT award for satellite-only QSOs
Added new award "DXCC SAT" that only counts satellite QSOs (QSOs with
satName field set). This adds a new "satellite_only" key to award
definitions that filters to only include satellite communications.

Award definition:
- ID: dxcc-sat
- Name: DXCC SAT
- Target: 100 DXCC entities
- Only satellite QSOs count

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:25:13 +01:00
a93d4ff85b refactor: remove DLD 80m CW award variant
Removed dld-80m-cw.json award definition. Only the main DLD award
remains, which covers all bands and modes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:58 +01:00
f3ee1be651 refactor: remove DLD variant awards (80m, 40m, CW)
Removed the following DLD award variants:
- dld-80m.json
- dld-40m.json
- dld-cw.json

Kept dld-80m-cw.json as it represents a more specific combination.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:09 +01:00
6c9aa1efe7 feat: add allowed_bands filter to award definitions
Adds a new "allowed_bands" key to award definitions that restricts which
bands count toward an award. If absent, all bands are allowed (default
behavior).

Applied to DXCC award to only count HF bands (160m-10m), excluding
VHF/UHF bands like 6m, 2m, and 70cm.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:19:32 +01:00
14c7319c9e refactor: remove DXCC CW award and rename DXCC Mixed Mode to DXCC
- Removed dxcc-cw.json award definition
- Renamed "DXCC Mixed Mode" to "DXCC" in dxcc.json
- Changed award ID from "dxcc-mixed" to "dxcc"
- Removed dxcc-cw.json from awards service file list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:14:33 +01:00
5792a98dca feat: sort band columns by wavelength instead of alphabetically
Band columns are now sorted by wavelength (longest to shortest):
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT.

Unknown bands are sorted to the end.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:10:24 +01:00
aa25d21c6b fix: count unique entities in column sums instead of QSO counts
Column sums now correctly count unique entities (e.g., unique DXCC
countries per band) instead of counting individual entity entries or
QSOs. This matches the award progress semantics.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:07:38 +01:00
e14da11a93 fix: correct column sum calculation for satellite QSOs
The SAT column sum was always showing 0 because it was filtering by
e.band === 'SAT', but entities still have their original band in the
data. Now it correctly identifies satellite QSOs by checking if any
QSOs have satName.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:04:51 +01:00
dc34fc20b1 feat: group satellite QSOs under SAT column in award detail
Satellite QSOs are now grouped under a "SAT" column instead of their
frequency band. The backend now includes satName in QSO data, and the
frontend detects satellite QSOs and groups them appropriately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:03:03 +01:00
c75e55d130 feat: show unique entity progress in award summary
Summary cards now display unique entity counts (e.g., unique DXCC countries)
instead of per-band/mode slot counts. This shows actual award progress:
Total entities worked, confirmed, and needed to reach the award target.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:58:21 +01:00
89edd07722 feat: make award summary respect mode filter and remove mode from table headers
Summary cards (Total, Confirmed, Worked, Needed) now update based on the
selected mode filter. Also removed redundant mode display from table column
headers since the mode is already visible in the filter dropdown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:52:13 +01:00
dd3beef9af feat: add award detail view with QSO count per slot and mode filter
- Award detail page now shows QSO counts per (entity, band, mode) slot
- Click count to open modal with all QSOs for that slot
- Click QSO in list to view full details
- Add mode filter: "Mixed Mode" aggregates by band, specific modes show (band, mode) columns
- Backend groups by slot and collects all confirmed QSOs in qsos array
- Frontend displays clickable count links (removed blue bubbles)

Backend changes:
- calculateDOKAwardProgress(): groups by (DOK, band, mode), collects qsos array
- calculatePointsAwardProgress(): updated for all count modes with qsos array
- getAwardEntityBreakdown(): groups by (entity, band, mode) slots

Frontend changes:
- Add mode filter dropdown with "Mixed Mode" default
- Update grouping logic to handle mixed mode vs specific mode
- Replace count badges with simple clickable links
- Add QSO list modal showing all QSOs per slot
- Add Mode column to QSO list (useful in mixed mode)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:34:55 +01:00
695000e35c docs: add comprehensive award system specification
Add complete specification document for the JSON-driven award
calculation system. Documents all rule types, filter operators,
QSO schema, and implementation guidance suitable for porting
to any programming language.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:50:38 +01:00
bdd8aa497d fix: admin action log and impersonation improvements
- Fix admin action log not displaying entries (use raw sqlite for self-join)
- Add global impersonation banner to all pages during impersonation
- Fix timestamp display in action log (convert Unix seconds to milliseconds)
- Add loginWithToken method to auth store for direct token authentication
- Fix /api/auth/me to include impersonatedBy field from JWT
- Remove duplicate impersonation code from admin page

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:26:20 +01:00
7c209e3270 fix: correct last-sync date and logout redirect issues
- Fix admin users last-sync showing 1970 instead of actual sync date
  - Changed from MAX(qsos.createdAt) to MAX(syncJobs.completedAt)
  - Added timestamp conversion (seconds to milliseconds) for proper Date serialization
- Fix logout redirect not working from admin dashboard
  - Changed from goto() to window.location.href for hard redirect
  - Ensures proper navigation after auth state changes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 17:49:27 +01:00
6d3291e331 chore: consolidate env templates and remove Docker docs from master
- Merge .env.production.template into .env.example
- Remove Docker Deployment section from CLAUDE.md (now on docker branch)
- Update README.md to reference .env.example
- Update environment variable documentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:08:27 +01:00
c0a471f7c2 chore: remove Docker files from master branch
Master is now for standalone/run-on-metal deployment only.
Docker-related files moved to dedicated 'docker' branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:05:39 +01:00
27 changed files with 2011 additions and 951 deletions

View File

@@ -1,61 +0,0 @@
# Dependencies
node_modules
# Note: bun.lock is needed by Dockerfile for --frozen-lockfile
# Environment
.env
.env.*
!.env.example
# Database - will be in volume mount
**/*.db
**/*.db-shm
**/*.db-wal
# Build outputs - built in container
src/frontend/build/
src/frontend/.svelte-kit/
src/frontend/dist/
build/
dist/
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Documentation (keep docs in image but don't need in build context)
# README.md
docs/
*.md
# Logs
logs/
*.log
backend.log
# Tests
*.test.js
*.test.ts
coverage/
# Docker files
Dockerfile
docker-compose.yml
.dockerignore
# CI/CD
.github/
.gitlab-ci.yml
# Data directory (for volume mount)
data/

View File

@@ -1,26 +0,0 @@
# Docker Environment Configuration
# Copy this file to .env and update with your values
# ============================================
# Application Settings
# ============================================
NODE_ENV=production
PORT=3001
LOG_LEVEL=debug
# ============================================
# Security (IMPORTANT: Change in production!)
# ============================================
# Generate a secure JWT secret with: openssl rand -base64 32
JWT_SECRET=change-this-in-production-use-openssl-rand-base64-32
# ============================================
# CORS Configuration
# ============================================
# Your application's public URL (e.g., https://awards.example.com)
VITE_APP_URL=
# Comma-separated list of allowed origins for CORS
# Only needed if not using same domain deployment
# Example: https://awards.example.com,https://www.awards.example.com
ALLOWED_ORIGINS=

View File

@@ -1,22 +1,47 @@
# Application Configuration
# Copy this file to .env and update with your values
# Hostname for the application (e.g., https://awards.dj7nt.de)
# ===================================================================
# Environment
# ===================================================================
# Development: development
# Production: production
NODE_ENV=development
# Log Level (debug, info, warn, error)
# Development: debug
# Production: info
LOG_LEVEL=debug
# Server Port (default: 3001)
PORT=3001
# ===================================================================
# URLs
# ===================================================================
# Frontend URL (e.g., https://awards.dj7nt.de)
# Leave empty for development (uses localhost)
VITE_APP_URL=
# API Base URL (in production, can be same domain or separate)
# Leave empty to use relative paths (recommended for same-domain deployment)
# API Base URL (leave empty for same-domain deployment)
# Only set if API is on different domain
VITE_API_BASE_URL=
# Allowed CORS origins for backend (comma-separated)
# Only needed for production if not using same domain
# Add all domains that should access the API
# Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de
ALLOWED_ORIGINS=
# JWT Secret (for production, use a strong random string)
# Generate with: openssl rand -base64 32
# ===================================================================
# Security
# ===================================================================
# JWT Secret (REQUIRED for production)
# Development: uses default if not set
# Production: Generate with: openssl rand -base64 32
JWT_SECRET=change-this-in-production
# Node Environment
NODE_ENV=development
# ===================================================================
# Database (Optional)
# ===================================================================
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/custom.db

View File

@@ -1,30 +0,0 @@
# Production Configuration Template
# Copy this file to .env.production and update with your production values
# Application Environment
NODE_ENV=production
# Log Level (debug, info, warn, error)
# Recommended: info for production
LOG_LEVEL=info
# Server Port (default: 3001)
PORT=3001
# Frontend URL (e.g., https://awards.dj7nt.de)
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# Allowed CORS origins (comma-separated)
# Add all domains that should access the API
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
# JWT Secret (REQUIRED - generate a strong secret!)
# Generate with: openssl rand -base64 32
JWT_SECRET=REPLACE_WITH_SECURE_RANDOM_STRING
# Database (if using external database)
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/production.db

102
CLAUDE.md
View File

@@ -77,58 +77,6 @@ test("hello world", () => {
});
```
## Docker Deployment
The application supports Docker deployment with single-port architecture and host-mounted database persistence.
**Quick Start**:
```bash
# Create environment file
cp .env.docker.example .env
# Generate JWT secret
openssl rand -base64 32 # Add to .env as JWT_SECRET
# Start application
docker-compose up -d --build
# Access at http://localhost:3001
```
**Architecture**:
- **Single Port**: Port 3001 serves both API (`/api/*`) and frontend (all other routes)
- **Database Persistence**: SQLite database stored at `./data/award.db` on host
- **Auto-initialization**: Database created from template on first startup
- **Health Checks**: Built-in health monitoring at `/api/health`
**Key Docker Files**:
- `Dockerfile`: Multi-stage build using official Bun runtime
- `docker-compose.yml`: Stack orchestration with volume mounts
- `docker-entrypoint.sh`: Database initialization logic
- `.env.docker.example`: Environment variable template
- `DOCKER.md`: Complete deployment documentation
**Environment Variables**:
- `NODE_ENV`: Environment mode (default: production)
- `PORT`: Application port (default: 3001)
- `LOG_LEVEL`: Logging level (debug/info/warn/error)
- `JWT_SECRET`: JWT signing secret (required, change in production!)
- `VITE_APP_URL`: Your application's public URL
- `ALLOWED_ORIGINS`: CORS allowed origins (comma-separated)
**Database Management**:
- Database location: `./data/award.db` (host-mounted volume)
- Backups: `cp data/award.db data/award.db.backup.$(date +%Y%m%d)`
- Reset: `docker-compose down -v && docker-compose up -d`
**Important Notes**:
- Database persists across container restarts/recreations
- Frontend dependencies are reinstalled in container to ensure correct platform binaries
- Uses custom init script (`src/backend/scripts/init-db.js`) with `bun:sqlite`
- Architecture-agnostic (works on x86, ARM64, etc.)
For detailed documentation, see `DOCKER.md`.
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
@@ -806,3 +754,53 @@ AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
- Tracks updated QSOs (restores previous state)
- Only allows canceling failed jobs or stale running jobs (>1 hour)
- Server-side validation prevents unauthorized cancellations
### Award Detail View (January 2025)
**Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format with entities as rows and band/mode combinations as columns.
**Key Features**:
- **QSO Count per Slot**: Each table cell shows the count of confirmed QSOs for that (entity, band, mode) combination
- **Drill-Down**: Click a count to open a modal showing all QSOs for that slot
- **QSO Detail**: Click any QSO in the list to view full QSO details
- **Mode Filter**: Filter by specific mode or view "Mixed Mode" (aggregates all modes by band)
**Backend Changes** (`src/backend/services/awards.service.js`):
- `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects all confirmed QSOs in `qsos` array
- `calculatePointsAwardProgress()`: Updated for all count modes (perBandMode, perStation, perQso) with `qsos` array
- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots for entity awards
**Response Structure**:
```javascript
{
entity: "F03",
band: "80m",
mode: "CW",
worked: true,
confirmed: true,
qsos: [
{ qsoId: 123, callsign: "DK0MU", mode: "CW", qsoDate: "20250115", timeOn: "123456", confirmed: true },
{ qsoId: 456, callsign: "DL1ABC", mode: "CW", qsoDate: "20250120", timeOn: "234500", confirmed: true }
]
}
```
**Mode Filter**:
- **Mixed Mode (default)**: Shows bands as columns, aggregates all modes
- Example: Columns are "80m", "40m", "20m"
- Clicking a count shows all QSOs for that band across all modes
- **Specific Mode**: Shows (band, mode) combinations as columns
- Example: Columns are "80m CW", "80m SSB", "40m CW"
- Filters to only show QSOs with that mode
**Frontend Components**:
- **Mode Filter Dropdown**: Located between summary cards and table
- Dynamically populated with available modes from the data
- Clear button appears when specific mode is selected
- **Count Badges**: Blue clickable links showing QSO count (removed bubbles, kept links)
- **QSO List Modal**: Shows all QSOs for selected slot with columns: Callsign, Date, Time, Mode
- **QSO Detail Modal**: Full QSO information (existing feature)
**Files Modified**:
- `src/backend/services/awards.service.js` - Backend grouping and QSO collection
- `src/frontend/src/routes/awards/[id]/+page.svelte` - Frontend display and interaction

219
DOCKER.md
View File

@@ -1,219 +0,0 @@
# Docker Deployment Guide
This guide covers deploying Quickawards using Docker.
## Quick Start
1. **Create environment file:**
```bash
cp .env.docker.example .env
```
2. **Generate secure JWT secret:**
```bash
openssl rand -base64 32
```
Copy the output and set it as `JWT_SECRET` in `.env`.
3. **Update `.env` with your settings:**
- `JWT_SECRET`: Strong random string (required)
- `VITE_APP_URL`: Your domain (e.g., `https://awards.example.com`)
- `ALLOWED_ORIGINS`: Your domain(s) for CORS
4. **Start the application:**
```bash
docker-compose up -d
```
5. **Access the application:**
- URL: http://localhost:3001
- Health check: http://localhost:3001/api/health
## Architecture
### Single Port Design
The Docker stack exposes a single port (3001) which serves both:
- **Backend API** (`/api/*`)
- **Frontend SPA** (all other routes)
### Database Persistence
- **Location**: `./data/award.db` (host-mounted volume)
- **Initialization**: Automatic on first startup
- **Persistence**: Database survives container restarts/recreations
### Startup Behavior
1. **First startup**: Database is created from template
2. **Subsequent startups**: Existing database is used
3. **Container recreation**: Database persists in volume
## Commands
### Start the application
```bash
docker-compose up -d
```
### View logs
```bash
docker-compose logs -f
```
### Stop the application
```bash
docker-compose down
```
### Rebuild after code changes
```bash
docker-compose up -d --build
```
### Stop and remove everything (including database volume)
```bash
docker-compose down -v
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NODE_ENV` | No | `production` | Environment mode |
| `PORT` | No | `3001` | Application port |
| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) |
| `JWT_SECRET` | **Yes** | - | JWT signing secret (change this!) |
| `VITE_APP_URL` | No | - | Your application's public URL |
| `ALLOWED_ORIGINS` | No | - | CORS allowed origins (comma-separated) |
## Database Management
### Backup the database
```bash
cp data/award.db data/award.db.backup.$(date +%Y%m%d)
```
### Restore from backup
```bash
docker-compose down
cp data/award.db.backup.YYYYMMDD data/award.db
docker-compose up -d
```
### Reset the database
```bash
docker-compose down -v
docker-compose up -d
```
## Troubleshooting
### Container won't start
```bash
# Check logs
docker-compose logs -f
# Check container status
docker-compose ps
```
### Database errors
```bash
# Check database file exists
ls -la data/
# Check database permissions
stat data/award.db
```
### Port already in use
Change the port mapping in `docker-compose.yml`:
```yaml
ports:
- "8080:3001" # Maps host port 8080 to container port 3001
```
### Health check failing
```bash
# Check if container is responding
curl http://localhost:3001/api/health
# Check container logs
docker-compose logs quickawards
```
## Production Deployment
### Using a Reverse Proxy (nginx)
Example nginx configuration:
```nginx
server {
listen 80;
server_name awards.example.com;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### SSL/TLS with Let's Encrypt
Use certbot with nginx:
```bash
sudo certbot --nginx -d awards.example.com
```
### Security Checklist
- [ ] Set strong `JWT_SECRET`
- [ ] Set `NODE_ENV=production`
- [ ] Set `LOG_LEVEL=info` (or `warn` in production)
- [ ] Configure `ALLOWED_ORIGINS` to your domain only
- [ ] Use HTTPS/TLS in production
- [ ] Regular database backups
- [ ] Monitor logs for suspicious activity
- [ ] Keep Docker image updated
## File Structure After Deployment
```
project/
├── data/
│ └── award.db # Persisted database (volume mount)
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env # Your environment variables
└── ... (source code)
```
## Building Without docker-compose
If you prefer to use `docker` directly:
```bash
# Build the image
docker build -t quickawards .
# Run the container
docker run -d \
--name quickawards \
-p 3001:3001 \
-v $(pwd)/data:/data \
-e JWT_SECRET=your-secret-here \
-e NODE_ENV=production \
quickawards
```

View File

@@ -1,72 +0,0 @@
# Multi-stage Dockerfile for Quickawards
# Uses official Bun runtime image
# ============================================
# Stage 1: Dependencies & Database Init
# ============================================
FROM oven/bun:1 AS builder
WORKDIR /app
# Install ALL dependencies (including devDependencies for drizzle-kit)
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source code (node_modules excluded by .dockerignore)
COPY . .
# Reinstall frontend dependencies to get correct platform binaries
RUN cd src/frontend && bun install
# Initialize database using custom script
# This creates a fresh database with the correct schema using bun:sqlite
RUN bun src/backend/scripts/init-db.js
# Build frontend
RUN bun run build
# ============================================
# Stage 2: Production Image
# ============================================
FROM oven/bun:1 AS production
WORKDIR /app
# Install production dependencies only
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
# Copy backend source and schema files
COPY src/backend ./src/backend
COPY award-definitions ./award-definitions
COPY drizzle.config.ts ./
# Copy frontend build from builder stage
COPY --from=builder /app/src/frontend/build ./src/frontend/build
# Copy initialized database from builder (will be used as template)
COPY --from=builder /app/src/backend/award.db /app/award.db.template
# Copy drizzle migrations (if they exist)
COPY --from=builder /app/drizzle ./drizzle
# Create directory for database volume mount
RUN mkdir -p /data
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set environment variables
ENV NODE_ENV=production \
PORT=3001 \
LOG_LEVEL=info
# Expose the application port
EXPOSE 3001
# Use entrypoint script to handle database initialization
ENTRYPOINT ["docker-entrypoint.sh"]
# Start the backend server
CMD ["bun", "run", "src/backend/index.js"]

View File

@@ -116,7 +116,7 @@ award/
│ └── package.json
├── award-definitions/ # Award rule definitions (JSON)
├── award.db # SQLite database (auto-created)
├── .env.production.template # Production configuration template
├── .env.example # Environment configuration template
├── bunfig.toml # Bun configuration
├── drizzle.config.js # Drizzle ORM configuration
├── package.json
@@ -149,20 +149,32 @@ cp .env.example .env
Edit `.env` with your configuration:
```env
# Application URL (for production deployment)
VITE_APP_URL=https://awards.dj7nt.de
# Environment (development/production)
NODE_ENV=development
# Log Level (debug/info/warn/error)
LOG_LEVEL=debug
# Server Port (default: 3001)
PORT=3001
# Frontend URL (e.g., https://awards.dj7nt.de)
# Leave empty for development (uses localhost)
VITE_APP_URL=
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
# Allowed CORS origins (comma-separated)
# Add all domains that should access the API
ALLOWED_ORIGINS=
# Environment
NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=change-this-in-production
```
**For development**: You can leave `.env` empty or use defaults.
**For development**: Use defaults above.
**For production**: Set `NODE_ENV=production`, `LOG_LEVEL=info`, and generate a strong `JWT_SECRET`.
4. Initialize the database with performance indexes:
```bash
@@ -414,20 +426,26 @@ bun run build
Create `.env` in the project root:
```bash
# Application URL
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (empty for same-domain)
VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
# Environment
NODE_ENV=production
# Database path (absolute path recommended)
DATABASE_PATH=/path/to/award/award.db
# Log Level (debug/info/warn/error)
LOG_LEVEL=info
# Server Port (default: 3001)
PORT=3001
# Frontend URL
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# Allowed CORS origins (comma-separated)
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
```
**Security**: Ensure `.env` has restricted permissions:

View File

@@ -1,19 +0,0 @@
{
"id": "dld-40m",
"name": "DLD 40m",
"description": "Confirm 100 unique DOKs on 40m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 40m band. Only DCL-confirmed QSOs with valid DOK information on 40m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "40m" }
]
}
}
}

View File

@@ -1,20 +0,0 @@
{
"id": "dld-80m-cw",
"name": "DLD 80m CW",
"description": "Confirm 100 unique DOKs on 80m using CW",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band using CW mode. Only DCL-confirmed QSOs with valid DOK information on 80m CW count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band. Only DCL-confirmed QSOs with valid DOK information on 80m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-cw",
"name": "DLD CW",
"description": "Confirm 100 unique DOKs using CW mode",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) using CW (Morse code). Each unique DOK on CW counts separately. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -1,27 +0,0 @@
{
"id": "dxcc-cw",
"name": "DXCC CW",
"description": "Confirm 100 DXCC entities using CW mode",
"caption": "Contact and confirm 100 different DXCC entities using CW mode only. Only QSOs made with CW mode count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"target": 100,
"type": "filtered",
"baseRule": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
},
"filters": {
"operator": "AND",
"filters": [
{
"field": "mode",
"operator": "eq",
"value": "CW"
}
]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"id": "dxcc-sat",
"name": "DXCC SAT",
"description": "Confirm 100 DXCC entities via satellite",
"caption": "Contact and confirm 100 different DXCC entities using satellite communications. Only satellite QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity",
"satellite_only": true
}
}

View File

@@ -1,13 +1,14 @@
{
"id": "dxcc-mixed",
"name": "DXCC Mixed Mode",
"description": "Confirm 100 DXCC entities on any band/mode",
"caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts. QSOs are confirmed when LoTW QSL is received.",
"id": "dxcc",
"name": "DXCC",
"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.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
"displayField": "entity",
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
}
}

View File

@@ -1,31 +0,0 @@
services:
quickawards:
build:
context: .
dockerfile: Dockerfile
container_name: quickawards
restart: unless-stopped
ports:
- "3001:3001"
environment:
# Application settings
NODE_ENV: production
PORT: 3001
LOG_LEVEL: info
# Security - IMPORTANT: Change these in production!
JWT_SECRET: ${JWT_SECRET:-change-this-in-production}
# CORS - Set to your domain in production
VITE_APP_URL: ${VITE_APP_URL:-}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
volumes:
# Host-mounted database directory
# Database will be created at ./data/award.db on first startup
- ./data:/data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View File

@@ -1,62 +0,0 @@
#!/bin/sh
set -e
# Docker container entrypoint script
# Handles database initialization on first startup
echo "=========================================="
echo "Quickawards - Docker Entrypoint"
echo "=========================================="
# Database location in volume mount
DB_PATH="/data/award.db"
TEMPLATE_DB="/app/award.db.template"
APP_DB_PATH="/app/src/backend/award.db"
# Check if database exists in the volume
if [ ! -f "$DB_PATH" ]; then
echo ""
echo "📦 Database not found in volume mount."
echo " Initializing from template database..."
echo ""
# Copy the template database (created during build with drizzle-kit push)
cp "$TEMPLATE_DB" "$DB_PATH"
# Ensure proper permissions
chmod 644 "$DB_PATH"
echo "✅ Database initialized at: $DB_PATH"
echo " This database will persist in the Docker volume."
else
echo ""
echo "✅ Existing database found at: $DB_PATH"
echo " Using existing database from volume mount."
fi
# Create symlink from app's expected db location to volume mount
# The app expects the database at src/backend/award.db
# We create a symlink so it points to the volume-mounted database
if [ -L "$APP_DB_PATH" ]; then
# Symlink already exists, remove it to refresh
rm "$APP_DB_PATH"
elif [ -e "$APP_DB_PATH" ]; then
# File or directory exists (shouldn't happen in production, but handle it)
echo "⚠ Warning: Found existing database at $APP_DB_PATH, removing..."
rm -f "$APP_DB_PATH"
fi
# Create symlink to the volume-mounted database
ln -s "$DB_PATH" "$APP_DB_PATH"
echo "✅ Created symlink: $APP_DB_PATH -> $DB_PATH"
echo ""
echo "=========================================="
echo "Starting Quickawards application..."
echo "Port: ${PORT:-3001}"
echo "Environment: ${NODE_ENV:-production}"
echo "=========================================="
echo ""
# Execute the main command (passed as CMD in Dockerfile)
exec "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import {
import {
getSystemStats,
getUserStats,
getAdminActions,
impersonateUser,
verifyImpersonation,
stopImpersonation,
@@ -434,9 +435,15 @@ const app = new Elysia()
return { success: false, error: 'User not found' };
}
// Include impersonatedBy from JWT if present (not stored in database)
const responseUser = {
...userData,
impersonatedBy: user.impersonatedBy,
};
return {
success: true,
user: userData,
user: responseUser,
};
})
@@ -860,6 +867,7 @@ const app = new Elysia()
message: `Deleted ${deleted} QSO(s)`,
};
} catch (error) {
logger.error('Failed to delete QSOs', { error: error.message, stack: error.stack });
set.status = 500;
return {
success: false,

View File

@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
* @returns {Promise<Array>} Array of admin actions
*/
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
let query = db
.select({
id: adminActions.id,
adminId: adminActions.adminId,
adminEmail: users.email,
adminCallsign: users.callsign,
actionType: adminActions.actionType,
targetUserId: adminActions.targetUserId,
targetEmail: sql`target_users.email`.as('targetEmail'),
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
details: adminActions.details,
createdAt: adminActions.createdAt,
})
.from(adminActions)
.leftJoin(users, eq(adminActions.adminId, users.id))
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
.orderBy(desc(adminActions.createdAt))
.limit(limit)
.offset(offset);
// Use raw SQL for the self-join (admin users and target users from same users table)
// Using bun:sqlite prepared statements for raw SQL
let query = `
SELECT
aa.id as id,
aa.admin_id as adminId,
admin_user.email as adminEmail,
admin_user.callsign as adminCallsign,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
target_user.email as targetEmail,
target_user.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
LEFT JOIN users target_user ON target_user.id = aa.target_user_id
`;
if (adminId) {
query = query.where(eq(adminActions.adminId, adminId));
const params = [];
if (adminId !== null) {
query += ` WHERE aa.admin_id = ?`;
params.push(adminId);
}
return await query;
query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
return sqlite.prepare(query).all(...params);
}
/**
@@ -127,7 +131,12 @@ export async function getUserStats() {
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)`,
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
lastSync: sql`MAX(${qsos.createdAt})`,
lastSync: sql`(
SELECT MAX(${syncJobs.completedAt})
FROM ${syncJobs}
WHERE ${syncJobs.userId} = ${users.id}
AND ${syncJobs.status} = 'completed'
)`.mapWith(Number),
createdAt: users.createdAt,
})
.from(users)
@@ -135,7 +144,11 @@ export async function getUserStats() {
.groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`);
return stats;
// Convert lastSync timestamps (seconds) to Date objects for JSON serialization
return stats.map(stat => ({
...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
}));
}
/**
@@ -228,24 +241,26 @@ export async function stopImpersonation(adminId, targetUserId) {
* @returns {Promise<Array>} Array of recent impersonation actions
*/
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
const impersonations = await db
.select({
id: adminActions.id,
actionType: adminActions.actionType,
targetUserId: adminActions.targetUserId,
targetEmail: sql`target_users.email`,
targetCallsign: sql`target_users.callsign`,
details: adminActions.details,
createdAt: adminActions.createdAt,
})
.from(adminActions)
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
.where(eq(adminActions.adminId, adminId))
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
.orderBy(desc(adminActions.createdAt))
.limit(limit);
// Use raw SQL for the self-join to avoid Drizzle alias issues
// Using bun:sqlite prepared statements for raw SQL
const query = `
SELECT
aa.id as id,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
u.email as targetEmail,
u.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users u ON u.id = aa.target_user_id
WHERE aa.admin_id = ?
AND aa.action_type LIKE 'impersonate%'
ORDER BY aa.created_at DESC
LIMIT ?
`;
return impersonations;
return sqlite.prepare(query).all(adminId, limit);
}
/**

View File

@@ -22,16 +22,12 @@ function loadAwardDefinitions() {
try {
const files = [
'dxcc.json',
'dxcc-cw.json',
'dxcc-sat.json',
'was.json',
'vucc-sat.json',
'sat-rs44.json',
'special-stations.json',
'dld.json',
'dld-80m.json',
'dld-40m.json',
'dld-cw.json',
'dld-80m-cw.json',
'73-on-73.json',
];
@@ -140,11 +136,27 @@ export async function calculateAwardProgress(userId, award, options = {}) {
logger.debug('QSOs after filters', { count: filteredQSOs.length });
}
// Apply allowed_bands filter if present
let finalQSOs = filteredQSOs;
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
finalQSOs = filteredQSOs.filter(qso => {
const band = qso.band;
return rules.allowed_bands.includes(band);
});
logger.debug('QSOs after allowed_bands filter', { count: finalQSOs.length });
}
// Apply satellite_only filter if present
if (rules.satellite_only) {
finalQSOs = finalQSOs.filter(qso => qso.satName);
logger.debug('QSOs after satellite_only filter', { count: finalQSOs.length });
}
// Calculate worked and confirmed entities
const workedEntities = new Set();
const confirmedEntities = new Set();
for (const qso of filteredQSOs) {
for (const qso of finalQSOs) {
const entity = getEntityValue(qso, rules.entityType);
if (entity) {
@@ -199,7 +211,7 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
}
// Track unique (DOK, band, mode) combinations
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object with qsos array
for (const qso of filteredQSOs) {
const dok = qso.darcDok;
@@ -212,29 +224,36 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
// Initialize combination if not exists
if (!dokCombinations.has(combinationKey)) {
dokCombinations.set(combinationKey, {
qsoId: qso.id,
entity: dok,
entityId: null,
entityName: dok,
band,
mode,
callsign: qso.callsign,
worked: false,
confirmed: false,
qsoDate: qso.qsoDate,
dclQslRdate: null,
qsos: [], // Array of confirmed QSOs for this slot
});
}
const detail = dokCombinations.get(combinationKey);
detail.worked = true;
// Check for DCL confirmation
// Check for DCL confirmation and add to qsos array
if (qso.dclQslRstatus === 'Y') {
if (!detail.confirmed) {
detail.confirmed = true;
detail.dclQslRdate = qso.dclQslRdate;
}
// Add this confirmed QSO to the qsos array
detail.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,
});
}
}
@@ -339,15 +358,13 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!combinationMap.has(combinationKey)) {
combinationMap.set(combinationKey, {
qsoId: qso.id,
callsign,
band,
mode,
points,
worked: true,
confirmed: false,
qsoDate: qso.qsoDate,
lotwQslRdate: null,
qsos: [], // Array of confirmed QSOs for this slot
});
}
@@ -355,8 +372,18 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = combinationMap.get(combinationKey);
if (!detail.confirmed) {
detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate;
}
// Add this confirmed QSO to the qsos array
detail.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,
});
}
}
@@ -378,15 +405,11 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!stationMap.has(callsign)) {
stationMap.set(callsign, {
qsoId: qso.id,
callsign,
points,
worked: true,
confirmed: false,
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
lotwQslRdate: null,
qsos: [], // Array of confirmed QSOs for this station
});
}
@@ -394,8 +417,18 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = stationMap.get(callsign);
if (!detail.confirmed) {
detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate;
}
// Add this confirmed QSO to the qsos array
detail.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,
});
}
}
@@ -415,6 +448,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (qso.lotwQslRstatus === 'Y') {
totalPoints += points;
// For perQso mode, each QSO is its own slot with a qsos array containing just itself
stationDetails.push({
qsoId: qso.id,
callsign,
@@ -424,7 +458,16 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
lotwQslRdate: qso.lotwQslRdate,
qsos: [{
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
satName: qso.satName,
confirmed: true,
}],
});
}
}
@@ -465,6 +508,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode,
callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot
};
} else if (countMode === 'perStation') {
return {
@@ -480,6 +524,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode,
callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this station
};
} else {
return {
@@ -495,6 +540,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode,
callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot (just this one QSO)
};
}
});
@@ -675,16 +721,34 @@ export async function getAwardEntityBreakdown(userId, awardId) {
// Apply filters
const filteredQSOs = applyFilters(allQSOs, rules.filters);
// Group by entity
const entityMap = new Map();
// Apply allowed_bands filter if present
let finalQSOs = filteredQSOs;
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
finalQSOs = filteredQSOs.filter(qso => {
const band = qso.band;
return rules.allowed_bands.includes(band);
});
}
for (const qso of filteredQSOs) {
// Apply satellite_only filter if present
if (rules.satellite_only) {
finalQSOs = finalQSOs.filter(qso => qso.satName);
}
// Group by (entity, band, mode) slot for entity awards
// This allows showing multiple QSOs per entity on different bands/modes
const slotMap = new Map(); // Key: "entity/band/mode" -> slot object
for (const qso of finalQSOs) {
const entity = getEntityValue(qso, rules.entityType);
if (!entity) continue;
if (!entityMap.has(entity)) {
// Determine what to display as the entity name
const band = qso.band || 'Unknown';
const mode = qso.mode || 'Unknown';
const slotKey = `${entity}/${band}/${mode}`;
// Determine what to display as the entity name (only on first create)
let displayName = String(entity);
if (rules.displayField) {
let rawValue = qso[rules.displayField];
@@ -696,27 +760,38 @@ export async function getAwardEntityBreakdown(userId, awardId) {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
}
entityMap.set(entity, {
qsoId: qso.id,
if (!slotMap.has(slotKey)) {
slotMap.set(slotKey, {
entity,
entityId: qso.entityId,
entityName: displayName,
band,
mode,
worked: false,
confirmed: false,
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
callsign: qso.callsign,
satName: qso.satName,
qsos: [], // Array of confirmed QSOs for this slot
});
}
const entityData = entityMap.get(entity);
entityData.worked = true;
const slotData = slotMap.get(slotKey);
slotData.worked = true;
// Check for LoTW confirmation and add to qsos array
if (qso.lotwQslRstatus === 'Y') {
entityData.confirmed = true;
entityData.lotwQslRdate = qso.lotwQslRdate;
if (!slotData.confirmed) {
slotData.confirmed = true;
}
// Add this confirmed QSO to the qsos array
slotData.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,
});
}
}
@@ -728,8 +803,8 @@ export async function getAwardEntityBreakdown(userId, awardId) {
caption: award.caption,
target: rules.target || 0,
},
entities: Array.from(entityMap.values()),
total: entityMap.size,
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length,
entities: Array.from(slotMap.values()),
total: slotMap.size,
confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length,
};
}

View File

@@ -1,5 +1,5 @@
import { db, logger } from '../config.js';
import { qsos, qsoChanges } from '../db/schema/index.js';
import { qsos, qsoChanges, syncJobs, awardProgress } from '../db/schema/index.js';
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js';
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
@@ -609,10 +609,58 @@ export async function getLastLoTWQSLDate(userId) {
/**
* Delete all QSOs for a user
* Also deletes related qso_changes records to satisfy foreign key constraints
*/
export async function deleteQSOs(userId) {
logger.debug('Deleting all QSOs for user', { userId });
// Step 1: Delete qso_changes that reference QSOs for this user
// Need to use a subquery since qso_changes doesn't have userId directly
const qsoIdsResult = await db
.select({ id: qsos.id })
.from(qsos)
.where(eq(qsos.userId, userId));
const qsoIds = qsoIdsResult.map(r => r.id);
let deletedChanges = 0;
if (qsoIds.length > 0) {
// Delete qso_changes where qsoId is in the list of QSO IDs
const changesResult = await db
.delete(qsoChanges)
.where(sql`${qsoChanges.qsoId} IN ${sql.raw(`(${qsoIds.join(',')})`)}`);
deletedChanges = changesResult.changes || changesResult || 0;
logger.debug('Deleted qso_changes', { count: deletedChanges });
}
// Step 2: Delete the QSOs
const result = await db.delete(qsos).where(eq(qsos.userId, userId));
return result;
logger.debug('Delete result', { result, type: typeof result, keys: Object.keys(result || {}) });
// Drizzle with SQLite/bun:sqlite returns various formats depending on driver
let count = 0;
if (result) {
if (typeof result === 'number') {
count = result;
} else if (result.changes !== undefined) {
count = result.changes;
} else if (result.rows !== undefined) {
count = result.rows;
} else if (result.meta?.changes !== undefined) {
count = result.meta.changes;
} else if (result.meta?.rows !== undefined) {
count = result.meta.rows;
}
}
logger.info('Deleted QSOs', { userId, count, deletedChanges });
// Invalidate caches for this user
await invalidateStatsCache(userId);
await invalidateUserCache(userId);
return count;
}
/**

View File

@@ -103,6 +103,15 @@ function createAuthStore() {
clearError: () => {
update((s) => ({ ...s, error: null }));
},
// Direct login with user object and token (for impersonation)
loginWithToken: (user, token) => {
if (browser) {
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
}
set({ user, token, loading: false, error: null });
},
};
}

View File

@@ -2,10 +2,42 @@
import { browser } from '$app/environment';
import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation';
import { adminAPI, authAPI } from '$lib/api.js';
let stoppingImpersonation = false;
function handleLogout() {
auth.logout();
goto('/auth/login');
// Use hard redirect to ensure proper navigation after logout
// goto() may not work properly due to SvelteKit client-side routing
if (browser) {
window.location.href = '/auth/login';
}
}
async function handleStopImpersonation() {
if (stoppingImpersonation) return;
try {
stoppingImpersonation = true;
const data = await adminAPI.stopImpersonation();
if (data.success) {
// Update auth store with admin user data and new token
auth.loginWithToken(data.user, data.token);
// Hard redirect to home page
if (browser) {
window.location.href = '/';
}
} else {
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to stop impersonation: ' + err.message);
} finally {
stoppingImpersonation = false;
}
}
</script>
@@ -35,6 +67,26 @@
</div>
</nav>
{/if}
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button
class="stop-impersonation-btn"
on:click={handleStopImpersonation}
disabled={stoppingImpersonation}
>
{stoppingImpersonation ? 'Stopping...' : 'Stop Impersonation'}
</button>
</div>
</div>
{/if}
<main>
<slot />
</main>
@@ -152,4 +204,51 @@
margin: 0;
font-size: 0.875rem;
}
/* Impersonation Banner */
.impersonation-banner {
background-color: #fff3cd;
border: 2px solid #ffc107;
padding: 0.75rem 1rem;
}
.impersonation-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.warning-icon {
font-size: 1.25rem;
}
.impersonation-text {
flex: 1;
font-size: 0.95rem;
color: #856404;
}
.stop-impersonation-btn {
background-color: #ffc107;
color: #000;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.stop-impersonation-btn:hover:not(:disabled) {
background-color: #e0a800;
}
.stop-impersonation-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js';
import { adminAPI } from '$lib/api.js';
import { adminAPI, authAPI } from '$lib/api.js';
import { browser } from '$app/environment';
let loading = true;
@@ -90,16 +90,16 @@
const data = await adminAPI.impersonate(userId);
if (data.success) {
// Store new token
// Store the new impersonation token
if (browser) {
localStorage.setItem('auth_token', data.token);
}
// Update auth store with new user data
auth.login({
...data.impersonating,
impersonatedBy: $auth.user.id,
});
// Fetch the full user profile (which includes impersonatedBy)
const profileData = await authAPI.getProfile();
// Update auth store with complete user data
auth.loginWithToken(profileData.user, data.token);
// Redirect to home page
window.location.href = '/';
@@ -114,32 +114,6 @@
}
}
async function handleStopImpersonation() {
try {
loading = true;
const data = await adminAPI.stopImpersonation();
if (data.success) {
// Store admin token
if (browser) {
localStorage.setItem('auth_token', data.token);
}
// Update auth store
auth.login(data.user);
alert(data.message);
window.location.reload();
} else {
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to stop impersonation: ' + err.message);
} finally {
loading = false;
}
}
async function handleDeleteUser(userId) {
const user = users.find(u => u.id === userId);
if (!user) return;
@@ -203,7 +177,11 @@
function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', {
// Handle Unix timestamps (seconds) by converting to milliseconds
const date = typeof dateString === 'number'
? new Date(dateString * 1000)
: new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -232,21 +210,6 @@
<div class="error">{error}</div>
{:else}
<div class="admin-dashboard">
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button class="stop-impersonation-btn" on:click={handleStopImpersonation}>
Stop Impersonation
</button>
</div>
</div>
{/if}
<h1>Admin Dashboard</h1>
<!-- Tab Navigation -->
@@ -573,45 +536,6 @@
color: #c00;
}
/* Impersonation Banner */
.impersonation-banner {
background-color: #fff3cd;
border: 2px solid #ffc107;
border-radius: 4px;
padding: 1rem;
margin-bottom: 2rem;
}
.impersonation-content {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.warning-icon {
font-size: 1.5rem;
}
.impersonation-text {
flex: 1;
font-size: 1rem;
}
.stop-impersonation-btn {
background-color: #ffc107;
color: #000;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.stop-impersonation-btn:hover {
background-color: #e0a800;
}
h1 {
margin-bottom: 1.5rem;
color: #333;
@@ -863,12 +787,12 @@
font-weight: 600;
}
.action-type.impostor_start {
.action-type.impersonate_start {
background-color: #ffc107;
color: #000;
}
.action-type.impostor_stop {
.action-type.impersonate_stop {
background-color: #28a745;
color: white;
}

View File

@@ -8,13 +8,53 @@
let loading = true;
let error = null;
let groupedData = [];
let bands = [];
let columns = []; // Array of {band, mode?} - mode is undefined for mixed mode
let selectedMode = 'Mixed Mode'; // Mode filter, default is all modes aggregated
// QSO detail modal state
let selectedQSO = null;
let showQSODetailModal = false;
let loadingQSO = false;
// QSO list modal state
let showQSOListModal = false;
let selectedSlotQSOs = [];
let selectedSlotInfo = null; // { entityName, band, mode }
// Get available modes from entities
$: availableModes = ['Mixed Mode', ...new Set(entities.map(e => e.mode).filter(Boolean).sort())];
// Band order by wavelength (longest to shortest), SAT at the end
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', 'SAT', '23cm', '13cm', '9cm', '6cm', '3cm'];
// Filter entities by selected mode for summary calculations
$: filteredEntities = selectedMode === 'Mixed Mode'
? entities
: entities.filter(e => e.mode === selectedMode);
// Calculate unique entity progress (for DXCC, DLD, etc.)
$: uniqueEntityProgress = (() => {
const uniqueEntities = new Map();
filteredEntities.forEach(e => {
const entityName = e.entityName || e.entity || 'Unknown';
if (!uniqueEntities.has(entityName)) {
uniqueEntities.set(entityName, { worked: false, confirmed: false });
}
const status = uniqueEntities.get(entityName);
if (e.worked) status.worked = true;
if (e.confirmed) status.confirmed = true;
});
return {
total: uniqueEntities.size,
worked: Array.from(uniqueEntities.values()).filter(s => s.worked).length,
confirmed: Array.from(uniqueEntities.values()).filter(s => s.confirmed).length
};
})();
onMount(async () => {
await loadAwardData();
});
@@ -56,17 +96,24 @@
}
function groupDataForTable() {
// Group by entity name, then create band columns
// Group by entity name, then create columns based on mode filter
const entityMap = new Map();
const bandsSet = new Set();
const columnSet = new Set();
const isMixedMode = selectedMode === 'Mixed Mode';
entities.forEach((entity) => {
// Skip if mode filter is set and entity doesn't match
if (!isMixedMode && entity.mode !== selectedMode) {
return;
}
const entityName = entity.entityName || entity.entity || 'Unknown';
if (!entityMap.has(entityName)) {
entityMap.set(entityName, {
entityName,
bands: new Map(),
slots: new Map(),
worked: entity.worked,
confirmed: entity.confirmed,
});
@@ -74,27 +121,69 @@
const entityData = entityMap.get(entityName);
if (entity.band) {
bandsSet.add(entity.band);
// Check if this is a satellite QSO - use "SAT" instead of band
const isSatellite = entity.qsos && entity.qsos.some(qso => qso.satName);
const band = isSatellite ? 'SAT' : (entity.band || 'Unknown');
if (!entityData.bands.has(entity.band)) {
entityData.bands.set(entity.band, []);
if (isMixedMode) {
// Mixed Mode: aggregate by band only, collect all QSOs across modes
columnSet.add(band);
if (!entityData.slots.has(band)) {
entityData.slots.set(band, {
band,
mode: null, // No specific mode in mixed mode
qsos: [], // Will be aggregated
confirmed: false,
});
}
// Add QSO info to this band
entityData.bands.get(entity.band).push({
qsoId: entity.qsoId,
callsign: entity.callsign,
mode: entity.mode,
band: entity.band,
const slot = entityData.slots.get(band);
// Add QSOs from this entity to the aggregated slot
if (entity.qsos && entity.qsos.length > 0) {
slot.qsos.push(...entity.qsos);
if (entity.confirmed) slot.confirmed = true;
}
} else {
// Specific Mode: group by (band, mode)
const mode = entity.mode || 'Unknown';
const columnKey = `${band}/${mode}`;
columnSet.add(columnKey);
entityData.slots.set(columnKey, {
band,
mode,
qsos: entity.qsos || [],
confirmed: entity.confirmed,
qsoDate: entity.qsoDate,
});
}
});
// Convert bands Set to sorted array
bands = Array.from(bandsSet).sort();
// Convert columnSet to sorted array of column objects
columns = Array.from(columnSet)
.map(key => {
if (isMixedMode) {
return { band: key, mode: null }; // key is just the band name
} else {
const [band, mode] = key.split('/');
return { band, mode };
}
})
.sort((a, b) => {
// Sort by band order (by wavelength), then by mode
const aBandIndex = bandOrder.indexOf(a.band);
const bBandIndex = bandOrder.indexOf(b.band);
const aIndex = aBandIndex === -1 ? 999 : aBandIndex;
const bIndex = bBandIndex === -1 ? 999 : bBandIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
// Same band, sort by mode if present
if (a.mode !== undefined && b.mode !== undefined) {
return (a.mode || '').localeCompare(b.mode || '');
}
return 0;
});
// Convert Map to array
groupedData = Array.from(entityMap.values());
@@ -108,15 +197,22 @@
const filteredEntities = getFilteredEntities();
const entityMap = new Map();
const bandsSet = new Set();
const columnSet = new Set();
const isMixedMode = selectedMode === 'Mixed Mode';
filteredEntities.forEach((entity) => {
// Skip if mode filter is set and entity doesn't match
if (!isMixedMode && entity.mode !== selectedMode) {
return;
}
const entityName = entity.entityName || entity.entity || 'Unknown';
if (!entityMap.has(entityName)) {
entityMap.set(entityName, {
entityName,
bands: new Map(),
slots: new Map(),
worked: entity.worked,
confirmed: entity.confirmed,
});
@@ -124,25 +220,68 @@
const entityData = entityMap.get(entityName);
if (entity.band) {
bandsSet.add(entity.band);
// Check if this is a satellite QSO - use "SAT" instead of band
const isSatellite = entity.qsos && entity.qsos.some(qso => qso.satName);
const band = isSatellite ? 'SAT' : (entity.band || 'Unknown');
if (!entityData.bands.has(entity.band)) {
entityData.bands.set(entity.band, []);
if (isMixedMode) {
// Mixed Mode: aggregate by band only
columnSet.add(band);
if (!entityData.slots.has(band)) {
entityData.slots.set(band, {
band,
mode: null,
qsos: [],
confirmed: false,
});
}
entityData.bands.get(entity.band).push({
qsoId: entity.qsoId,
callsign: entity.callsign,
mode: entity.mode,
band: entity.band,
const slot = entityData.slots.get(band);
if (entity.qsos && entity.qsos.length > 0) {
slot.qsos.push(...entity.qsos);
if (entity.confirmed) slot.confirmed = true;
}
} else {
// Specific Mode: group by (band, mode)
const mode = entity.mode || 'Unknown';
const columnKey = `${band}/${mode}`;
columnSet.add(columnKey);
entityData.slots.set(columnKey, {
band,
mode,
qsos: entity.qsos || [],
confirmed: entity.confirmed,
qsoDate: entity.qsoDate,
});
}
});
bands = Array.from(bandsSet).sort();
columns = Array.from(columnSet)
.map(key => {
if (isMixedMode) {
return { band: key, mode: null };
} else {
const [band, mode] = key.split('/');
return { band, mode };
}
})
.sort((a, b) => {
// Sort by band order (by wavelength), then by mode
const aBandIndex = bandOrder.indexOf(a.band);
const bBandIndex = bandOrder.indexOf(b.band);
const aIndex = aBandIndex === -1 ? 999 : aBandIndex;
const bIndex = bBandIndex === -1 ? 999 : bBandIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
// Same band, sort by mode if present
if (a.mode !== undefined && b.mode !== undefined) {
return (a.mode || '').localeCompare(b.mode || '');
}
return 0;
});
groupedData = Array.from(entityMap.values());
}
@@ -159,27 +298,69 @@
return filtered;
}
// Re-apply sort when entities or sort changes
$: if (entities.length > 0) {
// Re-apply sort when entities or mode changes
$: if (entities.length > 0 || selectedMode) {
applyFilter();
}
// Calculate band sums
$: bandSums = (() => {
// Calculate column sums - counts unique entities per column (not QSO counts)
$: columnSums = (() => {
const sums = new Map();
const hasPoints = entities.length > 0 && entities[0].points !== undefined;
const isMixedMode = selectedMode === 'Mixed Mode';
bands.forEach(band => {
columns.forEach(({ band, mode }) => {
const key = isMixedMode ? band : `${band}/${mode}`;
if (hasPoints) {
// Sum points for confirmed QSOs in this band
// Sum points for confirmed QSOs in this column
if (isMixedMode) {
const sum = entities
.filter(e => e.band === band && e.confirmed)
.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.confirmed;
}
return e.band === band && e.confirmed;
})
.reduce((total, e) => total + (e.points || 0), 0);
sums.set(band, sum);
sums.set(key, sum);
} else {
// Count confirmed QSOs in this band
const count = entities.filter(e => e.band === band && e.confirmed).length;
sums.set(band, count);
const sum = entities
.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.mode === mode && e.confirmed;
}
return e.band === band && e.mode === mode && e.confirmed;
})
.reduce((total, e) => total + (e.points || 0), 0);
sums.set(key, sum);
}
} else {
// Count unique entities in this column (not QSO counts)
if (isMixedMode) {
const matchedEntities = entities.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.confirmed;
}
return e.band === band && e.confirmed;
});
// Count unique entity names
const uniqueEntities = new Set(matchedEntities.map(e => e.entityName || e.entity || 'Unknown'));
sums.set(key, uniqueEntities.size);
} else {
const matchedEntities = entities.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.mode === mode && e.confirmed;
}
return e.band === band && e.mode === mode && e.confirmed;
});
// Count unique entity names
const uniqueEntities = new Set(matchedEntities.map(e => e.entityName || e.entity || 'Unknown'));
sums.set(key, uniqueEntities.size);
}
}
});
@@ -229,6 +410,23 @@
showQSODetailModal = false;
}
// QSO List Modal Functions
function openQSOListModal(slotData, entityName, band, mode) {
selectedSlotInfo = {
entityName,
band,
mode,
};
selectedSlotQSOs = slotData.qsos || [];
showQSOListModal = true;
}
function closeQSOListModal() {
selectedSlotInfo = null;
selectedSlotQSOs = [];
showQSOListModal = false;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
// ADIF format: YYYYMMDD
@@ -272,16 +470,16 @@
<div class="summary">
{#if entities.length > 0 && entities[0].points !== undefined}
{@const earnedPoints = entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
{@const targetPoints = award.target}
{@const neededPoints = Math.max(0, targetPoints - earnedPoints)}
<div class="summary-card">
<span class="summary-label">Total Combinations:</span>
<span class="summary-value">{entities.length}</span>
<span class="summary-value">{filteredEntities.length}</span>
</div>
<div class="summary-card confirmed">
<span class="summary-label">Confirmed:</span>
<span class="summary-value">{entities.filter((e) => e.confirmed).length}</span>
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
</div>
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
<span class="summary-label">Points:</span>
@@ -296,20 +494,18 @@
<span class="summary-value">{targetPoints}</span>
</div>
{:else}
{@const workedCount = entities.filter((e) => e.worked).length}
{@const confirmedCount = entities.filter((e) => e.confirmed).length}
{@const neededCount = award.target ? Math.max(0, award.target - workedCount) : entities.filter((e) => !e.worked).length}
{@const neededCount = award.target ? Math.max(0, award.target - uniqueEntityProgress.worked) : uniqueEntityProgress.total - uniqueEntityProgress.worked}
<div class="summary-card">
<span class="summary-label">Total:</span>
<span class="summary-value">{entities.length}</span>
<span class="summary-value">{uniqueEntityProgress.total}</span>
</div>
<div class="summary-card confirmed">
<span class="summary-label">Confirmed:</span>
<span class="summary-value">{confirmedCount}</span>
<span class="summary-value">{uniqueEntityProgress.confirmed}</span>
</div>
<div class="summary-card worked">
<span class="summary-label">Worked:</span>
<span class="summary-value">{workedCount}</span>
<span class="summary-value">{uniqueEntityProgress.worked}</span>
</div>
<div class="summary-card unworked">
<span class="summary-label">Needed:</span>
@@ -318,6 +514,18 @@
{/if}
</div>
<div class="mode-filter">
<label for="mode-select">Filter by mode:</label>
<select id="mode-select" bind:value={selectedMode}>
{#each availableModes as mode}
<option value={mode}>{mode}</option>
{/each}
</select>
{#if selectedMode !== 'Mixed Mode'}
<button class="clear-filter-btn" on:click={() => selectedMode = 'Mixed Mode'}>Clear</button>
{/if}
</div>
<div class="table-container">
{#if groupedData.length === 0}
<div class="empty">No entities match the current filter.</div>
@@ -326,7 +534,7 @@
<thead>
<tr>
<th class="entity-column">Entity</th>
{#each bands as band}
{#each columns as { band }}
<th class="band-column">{band}</th>
{/each}
</tr>
@@ -337,26 +545,23 @@
<td class="entity-cell">
<div class="entity-name">{row.entityName}</div>
</td>
{#each bands as band}
{@const qsos = row.bands.get(band) || []}
{#each columns as { band, mode }}
{@const columnKey = mode ? `${band}/${mode}` : band}
{@const slotData = row.slots.get(columnKey)}
<td class="band-cell">
{#if qsos.length > 0}
<div class="qso-list">
{#each qsos as qso}
<div
class="qso-entry {qso.confirmed ? 'qso-confirmed' : 'qso-worked'}"
on:click={() => openQSODetailModal(qso)}
on:keydown={(e) => e.key === 'Enter' && openQSODetailModal(qso)}
{#if slotData && slotData.qsos && slotData.qsos.length > 0}
<span
class="qso-count-link"
on:click={() => openQSOListModal(slotData, row.entityName, band, mode)}
on:keydown={(e) => e.key === 'Enter' && openQSOListModal(slotData, row.entityName, band, mode)}
role="button"
tabindex="0"
title="{slotData.qsos.length} QSO{slotData.qsos.length === 1 ? '' : 's'}"
>
<span class="callsign">{qso.callsign}</span>
<span class="mode">{qso.mode}</span>
</div>
{/each}
</div>
{slotData.qsos.length}
</span>
{:else}
<div class="no-qso">-</div>
<span class="no-qso">-</span>
{/if}
</td>
{/each}
@@ -368,8 +573,9 @@
<td class="sum-label">
<strong>Sum</strong>
</td>
{#each bands as band}
{@const sum = bandSums.get(band) ?? 0}
{#each columns as { band, mode }}
{@const columnKey = mode ? `${band}/${mode}` : band}
{@const sum = columnSums.get(columnKey) ?? 0}
<td class="sum-cell">
<strong>{sum}</strong>
</td>
@@ -564,6 +770,51 @@
</div>
{/if}
<!-- QSO List Modal -->
{#if showQSOListModal && selectedSlotInfo}
<div class="modal-backdrop" on:click={closeQSOListModal} on:keydown={(e) => e.key === 'Escape' && closeQSOListModal()} role="dialog" aria-modal="true">
<div class="modal-content qso-list-modal" on:click|stopPropagation>
<div class="modal-header">
<h2>QSOs for {selectedSlotInfo.entityName} ({selectedSlotInfo.band}{#if selectedSlotInfo.mode} {selectedSlotInfo.mode}{/if})</h2>
<button class="modal-close" on:click={closeQSOListModal} aria-label="Close modal">×</button>
</div>
<div class="modal-body">
{#if selectedSlotQSOs.length === 0}
<div class="empty">No QSOs found for this slot.</div>
{:else}
<table class="qso-list-table">
<thead>
<tr>
<th>Callsign</th>
<th>Date</th>
<th>Time</th>
<th>Mode</th>
</tr>
</thead>
<tbody>
{#each selectedSlotQSOs as qso}
<tr
class="qso-list-row"
on:click={() => { openQSODetailModal(qso); closeQSOListModal(); }}
on:keydown={(e) => e.key === 'Enter' && (openQSODetailModal(qso), closeQSOListModal())}
role="button"
tabindex="0"
>
<td class="callsign-cell">{qso.callsign}</td>
<td>{formatDate(qso.qsoDate)}</td>
<td>{formatTime(qso.timeOn)}</td>
<td>{qso.mode || '-'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{/if}
<style>
.container {
max-width: 1200px;
@@ -1037,4 +1288,129 @@
.modal-content::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Mode Filter */
.mode-filter {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.mode-filter label {
font-weight: 600;
color: #333;
margin: 0;
}
.mode-filter select {
padding: 0.5rem 2rem 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 4px;
background-color: white;
font-size: 0.95rem;
color: #333;
cursor: pointer;
min-width: 150px;
}
.mode-filter select:hover {
border-color: #4a90e2;
}
.mode-filter select:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.clear-filter-btn {
padding: 0.5rem 1rem;
background-color: #e0e0e0;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.clear-filter-btn:hover {
background-color: #d0d0d0;
}
/* QSO Count Link */
.qso-count-link {
cursor: pointer;
color: #4a90e2;
font-weight: 500;
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.qso-count-link:hover {
background-color: #f0f7ff;
text-decoration: underline;
}
.qso-count-link:focus {
outline: 2px solid #4a90e2;
outline-offset: -2px;
}
.no-qso {
color: #999;
}
/* QSO List Modal */
.qso-list-modal {
max-width: 500px;
}
.qso-list-table {
width: 100%;
border-collapse: collapse;
}
.qso-list-table th,
.qso-list-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.qso-list-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
font-size: 0.85rem;
text-transform: uppercase;
}
.qso-list-row {
cursor: pointer;
transition: background-color 0.2s;
}
.qso-list-row:hover {
background-color: #f0f7ff;
}
.qso-list-row:focus {
outline: 2px solid #4a90e2;
outline-offset: -2px;
}
.callsign-cell {
font-family: monospace;
font-weight: 600;
color: #333;
}
</style>

View File

@@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { authAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation';
@@ -93,7 +94,10 @@
function handleLogout() {
auth.logout();
goto('/auth/login');
// Use hard redirect to ensure proper navigation after logout
if (browser) {
window.location.href = '/auth/login';
}
}
</script>