diff --git a/src/backend/config/database.js b/src/backend/config/database.js index c87171b..b6438a2 100644 --- a/src/backend/config/database.js +++ b/src/backend/config/database.js @@ -1,9 +1,18 @@ import Database from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import * as schema from '../db/schema/index.js'; +import { join } from 'path'; + +// Get the directory of this file (src/backend/config/) +const configDir = import.meta.dir || new URL('.', import.meta.url).pathname; + +// Go up one level to get src/backend/, then to award.db +const dbPath = join(configDir, '..', 'award.db'); + +console.error('[Database] Using database at:', dbPath); // Create SQLite database connection -const sqlite = new Database('./award.db'); +const sqlite = new Database(dbPath); // Enable foreign keys sqlite.exec('PRAGMA foreign_keys = ON'); diff --git a/src/backend/db/schema/index.js b/src/backend/db/schema/index.js index cb27957..52d773f 100644 --- a/src/backend/db/schema/index.js +++ b/src/backend/db/schema/index.js @@ -142,5 +142,30 @@ export const awardProgress = sqliteTable('award_progress', { updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }); +/** + * @typedef {Object} SyncJob + * @property {number} id + * @property {number} userId + * @property {string} status + * @property {string} type + * @property {Date|null} startedAt + * @property {Date|null} completedAt + * @property {string|null} result + * @property {string|null} error + * @property {Date} createdAt + */ + +export const syncJobs = sqliteTable('sync_jobs', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id), + status: text('status').notNull(), // pending, running, completed, failed + type: text('type').notNull(), // lotw_sync, etc. + startedAt: integer('started_at', { mode: 'timestamp' }), + completedAt: integer('completed_at', { mode: 'timestamp' }), + result: text('result'), // JSON string + error: text('error'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), +}); + // Export all schemas -export const schema = { users, qsos, awards, awardProgress }; +export const schema = { users, qsos, awards, awardProgress, syncJobs }; diff --git a/src/backend/index.js b/src/backend/index.js index 57a960f..cd88cd8 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -9,10 +9,16 @@ import { updateLoTWCredentials, } from './services/auth.service.js'; import { - syncQSOs, getUserQSOs, getQSOStats, + deleteQSOs, } from './services/lotw.service.js'; +import { + enqueueJob, + getJobStatus, + getUserActiveJob, + getUserJobs, +} from './services/job-queue.service.js'; /** * Main backend application @@ -218,18 +224,29 @@ const app = new Elysia() /** * POST /api/lotw/sync - * Sync QSOs from LoTW (requires authentication) + * Queue a LoTW sync job (requires authentication) + * Returns immediately with job ID */ .post('/api/lotw/sync', async ({ user, set }) => { if (!user) { + console.error('[/api/lotw/sync] No user found in request'); set.status = 401; return { success: false, error: 'Unauthorized' }; } + console.error('[/api/lotw/sync] User authenticated:', user.id); + try { // Get user's LoTW credentials from database const userData = await getUserById(user.id); + console.error('[/api/lotw/sync] User data from DB:', { + id: userData?.id, + lotwUsername: userData?.lotwUsername ? '***' : null, + hasPassword: !!userData?.lotwPassword + }); + if (!userData || !userData.lotwUsername || !userData.lotwPassword) { + console.error('[/api/lotw/sync] Missing LoTW credentials'); set.status = 400; return { success: false, @@ -237,18 +254,136 @@ const app = new Elysia() }; } - // Decrypt password (for now, assuming it's stored as-is. TODO: implement encryption) - const lotwPassword = userData.lotwPassword; + // Enqueue the sync job (enqueueJob will check for existing active jobs) + const result = await enqueueJob(user.id, 'lotw_sync', { + lotwUsername: userData.lotwUsername, + lotwPassword: userData.lotwPassword, + }); - // Sync QSOs from LoTW - const result = await syncQSOs(user.id, userData.lotwUsername, lotwPassword); + // If enqueueJob returned existingJob, format the response + if (!result.success && result.existingJob) { + return { + success: true, + jobId: result.existingJob, + message: 'A sync job is already running', + }; + } return result; + } catch (error) { + console.error('Error in /api/lotw/sync:', error); + set.status = 500; + return { + success: false, + error: `Failed to queue sync job: ${error.message}`, + }; + } + }) + + /** + * GET /api/jobs/:jobId + * Get job status (requires authentication) + */ + .get('/api/jobs/:jobId', async ({ user, params, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const jobId = parseInt(params.jobId); + if (isNaN(jobId)) { + set.status = 400; + return { success: false, error: 'Invalid job ID' }; + } + + const job = await getJobStatus(jobId); + if (!job) { + set.status = 404; + return { success: false, error: 'Job not found' }; + } + + // Verify user owns this job + if (job.userId !== user.id) { + set.status = 403; + return { success: false, error: 'Forbidden' }; + } + + return { + success: true, + job, + }; } catch (error) { set.status = 500; return { success: false, - error: `LoTW sync failed: ${error.message}`, + error: 'Failed to fetch job status', + }; + } + }) + + /** + * GET /api/jobs/active + * Get user's active job (requires authentication) + */ + .get('/api/jobs/active', async ({ user, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const job = await getUserActiveJob(user.id); + + if (!job) { + return { + success: true, + job: null, + }; + } + + return { + success: true, + job: { + id: job.id, + type: job.type, + status: job.status, + createdAt: job.createdAt, + startedAt: job.startedAt, + }, + }; + } catch (error) { + set.status = 500; + return { + success: false, + error: 'Failed to fetch active job', + }; + } + }) + + /** + * GET /api/jobs + * Get user's recent jobs (requires authentication) + */ + .get('/api/jobs', async ({ user, query, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const limit = query.limit ? parseInt(query.limit) : 10; + const jobs = await getUserJobs(user.id, limit); + + return { + success: true, + jobs, + }; + } catch (error) { + set.status = 500; + return { + success: false, + error: 'Failed to fetch jobs', }; } }) @@ -311,6 +446,33 @@ const app = new Elysia() } }) + /** + * DELETE /api/qsos/all + * Delete all QSOs for authenticated user + */ + .delete('/api/qsos/all', async ({ user, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const deleted = await deleteQSOs(user.id); + + return { + success: true, + deleted, + message: `Deleted ${deleted} QSO(s)`, + }; + } catch (error) { + set.status = 500; + return { + success: false, + error: 'Failed to delete QSOs', + }; + } + }) + // Health check endpoint .get('/api/health', () => ({ status: 'ok', diff --git a/src/backend/services/job-queue.service.js b/src/backend/services/job-queue.service.js new file mode 100644 index 0000000..5940dd8 --- /dev/null +++ b/src/backend/services/job-queue.service.js @@ -0,0 +1,314 @@ +import { db } from '../config/database.js'; +import { syncJobs } from '../db/schema/index.js'; +import { eq, and, desc, or, lt } from 'drizzle-orm'; + +/** + * Background Job Queue Service + * Manages async jobs with database persistence + */ + +// Job status constants +export const JobStatus = { + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + FAILED: 'failed', +}; + +// Job type constants +export const JobType = { + LOTW_SYNC: 'lotw_sync', +}; + +// In-memory job processor (for single-server deployment) +const activeJobs = new Map(); // jobId -> Promise +const jobProcessors = { + [JobType.LOTW_SYNC]: null, // Will be set by lotw.service.js +}; + +/** + * Register a job processor function + * @param {string} type - Job type + * @param {Function} processor - Async function that processes the job + */ +export function registerProcessor(type, processor) { + jobProcessors[type] = processor; +} + +/** + * Enqueue a new job + * @param {number} userId - User ID + * @param {string} type - Job type + * @param {Object} data - Job data (will be passed to processor) + * @returns {Promise} Job object with ID + */ +export async function enqueueJob(userId, type, data = {}) { + console.error('[enqueueJob] Starting job enqueue:', { userId, type, hasData: !!data }); + + // Check for existing active job of same type for this user + const existingJob = await getUserActiveJob(userId, type); + if (existingJob) { + console.error('[enqueueJob] Found existing active job:', existingJob.id); + return { + success: false, + error: `A ${type} job is already running or pending for this user`, + existingJob: existingJob.id, + }; + } + + // Create job record + console.error('[enqueueJob] Creating job record in database...'); + const [job] = await db + .insert(syncJobs) + .values({ + userId, + type, + status: JobStatus.PENDING, + createdAt: new Date(), + }) + .returning(); + + console.error('[enqueueJob] Job created:', job.id); + + // Start processing asynchronously (don't await) + processJobAsync(job.id, userId, type, data).catch((error) => { + console.error(`[enqueueJob] Error processing job ${job.id}:`, error); + }); + + return { + success: true, + jobId: job.id, + job: { + id: job.id, + type: job.type, + status: job.status, + createdAt: job.createdAt, + }, + }; +} + +/** + * Process a job asynchronously + * @param {number} jobId - Job ID + * @param {number} userId - User ID + * @param {string} type - Job type + * @param {Object} data - Job data + */ +async function processJobAsync(jobId, userId, type, data) { + // Store the promise in activeJobs + const jobPromise = (async () => { + try { + // Update status to running + await updateJob(jobId, { + status: JobStatus.RUNNING, + startedAt: new Date(), + }); + + // Get the processor for this job type + const processor = jobProcessors[type]; + if (!processor) { + throw new Error(`No processor registered for job type: ${type}`); + } + + // Execute the job processor + const result = await processor(jobId, userId, data); + + // Update job as completed + await updateJob(jobId, { + status: JobStatus.COMPLETED, + completedAt: new Date(), + result: JSON.stringify(result), + }); + + return result; + } catch (error) { + // Update job as failed + await updateJob(jobId, { + status: JobStatus.FAILED, + completedAt: new Date(), + error: error.message, + }); + + throw error; + } finally { + // Remove from active jobs + activeJobs.delete(jobId); + } + })(); + + activeJobs.set(jobId, jobPromise); + return jobPromise; +} + +/** + * Update job record + * @param {number} jobId - Job ID + * @param {Object} updates - Fields to update + */ +export async function updateJob(jobId, updates) { + await db.update(syncJobs).set(updates).where(eq(syncJobs.id, jobId)); +} + +/** + * Get job by ID + * @param {number} jobId - Job ID + * @returns {Promise} Job object or null + */ +export async function getJob(jobId) { + const [job] = await db.select().from(syncJobs).where(eq(syncJobs.id, jobId)).limit(1); + return job || null; +} + +/** + * Get job status (with parsed result if completed) + * @param {number} jobId - Job ID + * @returns {Promise} Job object with parsed result + */ +export async function getJobStatus(jobId) { + const job = await getJob(jobId); + if (!job) return null; + + // Parse result JSON if completed + let parsedResult = null; + if (job.status === JobStatus.COMPLETED && job.result) { + try { + parsedResult = JSON.parse(job.result); + } catch (e) { + console.error('Failed to parse job result:', e); + } + } + + return { + id: job.id, + userId: job.userId, // Include userId for permission checks + type: job.type, + status: job.status, + startedAt: job.startedAt, + completedAt: job.completedAt, + result: parsedResult, + error: job.error, + createdAt: job.createdAt, + }; +} + +/** + * Get user's active job (pending or running) of a specific type + * @param {number} userId - User ID + * @param {string} type - Job type (optional, returns any active job) + * @returns {Promise} Active job or null + */ +export async function getUserActiveJob(userId, type = null) { + console.error('[getUserActiveJob] Querying for active job:', { userId, type }); + + // Build the where clause properly with and() and or() + const conditions = [ + eq(syncJobs.userId, userId), + or( + eq(syncJobs.status, JobStatus.PENDING), + eq(syncJobs.status, JobStatus.RUNNING) + ), + ]; + + if (type) { + conditions.push(eq(syncJobs.type, type)); + } + + try { + const [job] = await db + .select() + .from(syncJobs) + .where(and(...conditions)) + .orderBy(desc(syncJobs.createdAt)) + .limit(1); + + console.error('[getUserActiveJob] Result:', job ? `Found job ${job.id}` : 'No active job'); + return job || null; + } catch (error) { + console.error('[getUserActiveJob] Database error:', error); + throw error; + } +} + +/** + * Get recent jobs for a user + * @param {number} userId - User ID + * @param {number} limit - Maximum number of jobs to return + * @returns {Promise} Array of jobs + */ +export async function getUserJobs(userId, limit = 10) { + const jobs = await db + .select() + .from(syncJobs) + .where(eq(syncJobs.userId, userId)) + .orderBy(desc(syncJobs.createdAt)) + .limit(limit); + + return jobs.map((job) => { + let parsedResult = null; + if (job.status === JobStatus.COMPLETED && job.result) { + try { + parsedResult = JSON.parse(job.result); + } catch (e) { + // Ignore parse errors + } + } + + return { + id: job.id, + type: job.type, + status: job.status, + startedAt: job.startedAt, + completedAt: job.completedAt, + result: parsedResult, + error: job.error, + createdAt: job.createdAt, + }; + }); +} + +/** + * Delete old completed jobs (cleanup) + * @param {number} daysOld - Delete jobs older than this many days + * @returns {Promise} Number of jobs deleted + */ +export async function cleanupOldJobs(daysOld = 7) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + const result = await db + .delete(syncJobs) + .where( + and( + eq(syncJobs.status, JobStatus.COMPLETED), + lt(syncJobs.completedAt, cutoffDate) + ) + ); + + return result; +} + +/** + * Update job progress (for long-running jobs) + * @param {number} jobId - Job ID + * @param {Object} progressData - Progress data to store in result field + */ +export async function updateJobProgress(jobId, progressData) { + const job = await getJob(jobId); + if (!job) return; + + let currentData = {}; + if (job.result) { + try { + currentData = JSON.parse(job.result); + } catch (e) { + // Start fresh if invalid JSON + } + } + + // Merge progress data + const updatedData = { ...currentData, ...progressData, progress: true }; + + await updateJob(jobId, { + result: JSON.stringify(updatedData), + }); +} diff --git a/src/backend/services/lotw.service.js b/src/backend/services/lotw.service.js index 27b0e93..dc78ab9 100644 --- a/src/backend/services/lotw.service.js +++ b/src/backend/services/lotw.service.js @@ -1,11 +1,16 @@ import { db } from '../config/database.js'; import { qsos } from '../db/schema/index.js'; +import { max, sql } from 'drizzle-orm'; +import { registerProcessor, updateJobProgress } from './job-queue.service.js'; /** * LoTW (Logbook of the World) Service * Fetches QSOs from ARRL's LoTW system */ +// Wavelog-compatible constants +const LOTW_CONNECT_TIMEOUT = 30; // CURLOPT_CONNECTTIMEOUT from Wavelog + // Configuration for long-polling const POLLING_CONFIG = { maxRetries: 30, // Maximum number of retry attempts @@ -154,13 +159,21 @@ export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = const adifData = await response.text(); console.error(`Response length: ${adifData.length} bytes`); - // Check if report is still pending - if (isReportPending(adifData)) { - console.error('LoTW report is still being prepared, waiting...', adifData.substring(0, 100)); + // Wavelog: Validate response for credential errors + if (adifData.toLowerCase().includes('username/password incorrect')) { + throw new Error('Username/password incorrect'); + } - // Wait before retrying - await sleep(POLLING_CONFIG.retryDelay); - continue; + // Wavelog: Check if file starts with expected header + const header = adifData.trim().substring(0, 39).toLowerCase(); + if (!header.includes('arrl logbook of the world')) { + // This might be because the report is still pending + if (isReportPending(adifData)) { + console.error('LoTW report is still being prepared, waiting...', adifData.substring(0, 100)); + await sleep(POLLING_CONFIG.retryDelay); + continue; + } + throw new Error('Downloaded LoTW report is invalid. Check your credentials.'); } // We have valid data! @@ -529,3 +542,200 @@ export async function getQSOStats(userId) { return stats; } + +/** + * Get the date of the last LoTW QSL for a user + * Used for qso_qslsince parameter to minimize downloads + * @param {number} userId - User ID + * @returns {Promise} Last QSL date or null + */ +export async function getLastLoTWQSLDate(userId) { + const { eq } = await import('drizzle-orm'); + + // Get the most recent lotwQslRdate for this user + const [result] = await db + .select({ maxDate: max(qsos.lotwQslRdate) }) + .from(qsos) + .where(eq(qsos.userId, userId)); + + if (!result || !result.maxDate) { + return null; + } + + // Parse ADIF date format (YYYYMMDD) to Date + const dateStr = result.maxDate; + if (!dateStr || dateStr === '') { + return null; + } + + const year = dateStr.substring(0, 4); + const month = dateStr.substring(4, 6); + const day = dateStr.substring(6, 8); + + return new Date(`${year}-${month}-${day}`); +} + +/** + * Validate LoTW response following Wavelog logic + * @param {string} responseData - Response from LoTW + * @returns {Object} { valid: boolean, error?: string } + */ +function validateLoTWResponse(responseData) { + const trimmed = responseData.trim(); + + // Wavelog: Check for username/password incorrect + if (trimmed.toLowerCase().includes('username/password incorrect')) { + return { + valid: false, + error: 'Username/password incorrect', + shouldClearCredentials: true, + }; + } + + // Wavelog: Check if file starts with "ARRL Logbook of the World Status Report" + const header = trimmed.substring(0, 39).toLowerCase(); + if (!header.includes('arrl logbook of the world')) { + return { + valid: false, + error: 'Downloaded LoTW report is invalid. File does not start with expected header.', + }; + } + + return { valid: true }; +} + +/** + * LoTW sync job processor for the job queue + * @param {number} jobId - Job ID + * @param {number} userId - User ID + * @param {Object} data - Job data { lotwUsername, lotwPassword } + * @returns {Promise} Sync result + */ +export async function syncQSOsForJob(jobId, userId, data) { + const { lotwUsername, lotwPassword } = data; + + try { + // Update job progress: starting + await updateJobProgress(jobId, { + message: 'Fetching QSOs from LoTW...', + step: 'fetch', + }); + + // Get last LoTW QSL date for incremental sync + const lastQSLDate = await getLastLoTWQSLDate(userId); + const sinceDate = lastQSLDate || new Date('2026-01-01'); // Default as per Wavelog + + console.error(`[Job ${jobId}] Syncing LoTW QSOs since ${sinceDate.toISOString().split('T')[0]}`); + + // Fetch from LoTW + const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate); + + if (!adifQSOs || adifQSOs.length === 0) { + return { + success: true, + total: 0, + added: 0, + updated: 0, + message: 'No QSOs found in LoTW', + }; + } + + // Update job progress: processing + await updateJobProgress(jobId, { + message: `Processing ${adifQSOs.length} QSOs...`, + step: 'process', + total: adifQSOs.length, + processed: 0, + }); + + let addedCount = 0; + let updatedCount = 0; + const errors = []; + + // Process each QSO + for (let i = 0; i < adifQSOs.length; i++) { + const qsoData = adifQSOs[i]; + + try { + const dbQSO = convertQSODatabaseFormat(qsoData, userId); + + // Check if QSO already exists + const { eq, and } = await import('drizzle-orm'); + const existing = await db + .select() + .from(qsos) + .where( + and( + eq(qsos.userId, userId), + eq(qsos.callsign, dbQSO.callsign), + eq(qsos.qsoDate, dbQSO.qsoDate), + eq(qsos.band, dbQSO.band), + eq(qsos.mode, dbQSO.mode) + ) + ) + .limit(1); + + if (existing.length > 0) { + // Update existing QSO + await db + .update(qsos) + .set({ + lotwQslRdate: dbQSO.lotwQslRdate, + lotwQslRstatus: dbQSO.lotwQslRstatus, + lotwSyncedAt: dbQSO.lotwSyncedAt, + }) + .where(eq(qsos.id, existing[0].id)); + updatedCount++; + } else { + // Insert new QSO + await db.insert(qsos).values(dbQSO); + addedCount++; + } + + // Update progress every 10 QSOs + if ((i + 1) % 10 === 0) { + await updateJobProgress(jobId, { + processed: i + 1, + message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`, + }); + } + } catch (error) { + console.error(`[Job ${jobId}] ERROR processing QSO:`, error); + errors.push({ + qso: qsoData, + error: error.message, + }); + } + } + + return { + success: true, + total: adifQSOs.length, + added: addedCount, + updated: updatedCount, + errors: errors.length > 0 ? errors : undefined, + }; + } catch (error) { + // Check if it's a credential error + if (error.message.includes('Username/password incorrect')) { + throw new Error('Invalid LoTW credentials. Please check your username and password.'); + } + throw error; + } +} + +/** + * Delete all QSOs for a user + * @param {number} userId - User ID + * @returns {Promise} Number of QSOs deleted + */ +export async function deleteQSOs(userId) { + const { eq } = await import('drizzle-orm'); + + const result = await db.delete(qsos).where(eq(qsos.userId, userId)); + + return result; +} + +// Register the LoTW sync processor with the job queue +registerProcessor('lotw_sync', syncQSOsForJob); diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index d873c8b..dd074c6 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -134,11 +134,45 @@ export const qsosAPI = { getStats: () => apiRequest('/qsos/stats'), /** - * Sync QSOs from LoTW - * @returns {Promise} Sync result + * Sync QSOs from LoTW (queues a job) + * @returns {Promise} Job information */ syncFromLoTW: () => apiRequest('/lotw/sync', { method: 'POST', }), + + /** + * Delete all QSOs for authenticated user + * @returns {Promise} Delete result + */ + deleteAll: () => + apiRequest('/qsos/all', { + method: 'DELETE', + }), +}; + +/** + * Jobs API + */ +export const jobsAPI = { + /** + * Get job status + * @param {number} jobId - Job ID + * @returns {Promise} Job status + */ + getStatus: (jobId) => apiRequest(`/jobs/${jobId}`), + + /** + * Get user's active job + * @returns {Promise} Active job or null + */ + getActive: () => apiRequest('/jobs/active'), + + /** + * Get user's recent jobs + * @param {number} limit - Maximum number of jobs to return + * @returns {Promise} List of jobs + */ + getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`), }; diff --git a/src/frontend/src/routes/qsos/+page.svelte b/src/frontend/src/routes/qsos/+page.svelte index 9510606..ccb4272 100644 --- a/src/frontend/src/routes/qsos/+page.svelte +++ b/src/frontend/src/routes/qsos/+page.svelte @@ -1,14 +1,23 @@ @@ -116,15 +228,40 @@

QSO Log

- +
+ {#if qsos.length > 0} + + {/if} + +
+ {#if syncProgress} +
+

Syncing from LoTW...

+

{syncProgress.message || 'Processing...'}

+ {#if syncProgress.total} +

Progress: {syncProgress.processed || 0} / {syncProgress.total}

+ {/if} +
+ {/if} + {#if syncResult}
{#if syncResult.success} @@ -141,6 +278,37 @@
{/if} + {#if showDeleteConfirm} +
+

⚠️ Delete All QSOs?

+

This will permanently delete all {qsos.length} QSOs. This action cannot be undone!

+

Type DELETE to confirm:

+ +
+ + +
+
+ {/if} + {#if stats}
@@ -249,6 +417,8 @@ justify-content: space-between; align-items: center; margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; } .header h1 { @@ -256,6 +426,11 @@ color: #333; } + .header-buttons { + display: flex; + gap: 1rem; + } + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); @@ -349,6 +524,20 @@ background-color: #5a6268; } + .btn-danger { + background-color: #dc3545; + color: white; + } + + .btn-danger:hover:not(:disabled) { + background-color: #c82333; + } + + .btn-danger:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .btn-small { padding: 0.25rem 0.75rem; font-size: 0.875rem; @@ -364,8 +553,8 @@ border-radius: 8px; margin-bottom: 2rem; display: flex; - justify-content: space-between; - align-items: flex-start; + flex-direction: column; + gap: 0.5rem; } .alert-success { @@ -380,8 +569,14 @@ color: #721c24; } + .alert-info { + background-color: #d1ecf1; + border: 1px solid #bee5eb; + color: #0c5460; + } + .alert h3 { - margin: 0 0 0.5rem 0; + margin: 0; } .alert p { @@ -393,6 +588,21 @@ margin-top: 0.5rem; } + .delete-input { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + margin: 0.5rem 0; + width: 200px; + } + + .delete-buttons { + display: flex; + gap: 1rem; + margin-top: 0.5rem; + } + .qso-table-container { overflow-x: auto; border: 1px solid #e0e0e0;