also looking at this
Migrate :text/x-text bindings to {{ }} interpolation (stx 0.2.72)
#990
50 files
+611
-371
| @@ -30,13 +30,13 @@ export default new Action({ | ||
| 30 | 30 | |
| 31 | 31 | async handle(request) { |
| 32 | 32 | const userId = request.get<number>('user_id') |
| 33 | const limit = Math.min(Math.max(request.get<number>('limit') || 100, 1), 500) | |
| 33 | const page = readPageParams(request, { defaultLimit: 100, maxLimit: 500 }) | |
| 34 | 34 | |
| 35 | 35 | try { |
| 36 | 36 | const query = userId |
| 37 | 37 | ? Activity.where('user_id', '=', userId) |
| 38 | 38 | : Activity.query() |
| 39 | const rows = (await query.orderBy('completed_at', 'desc').limit(limit).get()) ?? [] | |
| 39 | const rows = (await query.orderBy('completed_at', 'desc').get()) ?? [] | |
| 40 | 40 | |
| 41 | 41 | // Batch-load owners + trails so the feed gets names without N+1 lookups. |
| 42 | 42 | const userIds = [...new Set(rows.map((a: any) => a.user_id).filter(Boolean))] |
| @@ -71,7 +71,8 @@ export default new Action({ | ||
| 71 | 71 | } |
| 72 | 72 | }) |
| 73 | 73 | |
| 74 | return response.json({ activities, meta: { total: activities.length } }) | |
| 74 | const { items, meta } = paginate(activities, page) | |
| 75 | return response.json({ activities: items, meta }) | |
| 75 | 76 | } |
| 76 | 77 | catch (error) { |
| 77 | 78 | console.error('[activities] index failed:', error) |
| @@ -33,7 +33,10 @@ export default new Action({ | ||
| 33 | 33 | })) |
| 34 | 34 | .sort((a: any, b: any) => Date.parse(b.createdAt ?? '') - Date.parse(a.createdAt ?? '')) |
| 35 | 35 | |
| 36 | return response.json({ success: true, challenges, meta: { total: challenges.length } }) | |
| 36 | // Generous default — the page filters the full set into client-side | |
| 37 | // tabs (active/sent/received/completed) and has no load-more (#978 review). | |
| 38 | const paged = paginate(challenges, readPageParams(request, { defaultLimit: 200, maxLimit: 200 })) | |
| 39 | return response.json({ success: true, challenges: paged.items, meta: paged.meta }) | |
| 37 | 40 | } |
| 38 | 41 | catch (error) { |
| 39 | 42 | console.error('[challenges] index failed:', error) |
| @@ -73,7 +73,10 @@ export default new Action({ | ||
| 73 | 73 | }) |
| 74 | 74 | .sort((a: any, b: any) => b.memberCount - a.memberCount || a.id - b.id) |
| 75 | 75 | |
| 76 | return response.json({ success: true, clubs: result, meta: { total: result.length } }) | |
| 76 | // Generous default — the clubs page has no load-more and expects the | |
| 77 | // full set; ?limit/?offset still paginate on demand (#978 review). | |
| 78 | const paged = paginate(result, readPageParams(request, { defaultLimit: 200, maxLimit: 200 })) | |
| 79 | return response.json({ success: true, clubs: paged.items, meta: paged.meta }) | |
| 77 | 80 | } |
| 78 | 81 | catch (error) { |
| 79 | 82 | console.error('[clubs] index failed:', error) |
| @@ -5,8 +5,6 @@ | ||
| 5 | 5 | // returns the most active athletes as a discover list. Public read; response |
| 6 | 6 | // includes enough stats to render discover cards without extra calls. |
| 7 | 7 | |
| 8 | const SEARCH_LIMIT = 20 | |
| 9 | ||
| 10 | 8 | export default new Action({ |
| 11 | 9 | name: 'User Search', |
| 12 | 10 | description: 'Search athletes by name (discover list when no query)', |
| @@ -17,9 +15,12 @@ export default new Action({ | ||
| 17 | 15 | const q = typeof qRaw === 'string' ? qRaw.trim() : '' |
| 18 | 16 | |
| 19 | 17 | try { |
| 18 | // Fetch the full match set, then paginate — so meta.total/hasMore reflect | |
| 19 | // the real count (a DB-side .limit() before paginate() would cap total at | |
| 20 | // the page size and make hasMore always false, #978 review). | |
| 20 | 21 | const users = q.length >= 2 |
| 21 | ? (await User.where('name', 'like', `%${q}%`).limit(SEARCH_LIMIT).get()) ?? [] | |
| 22 | : (await User.query().limit(SEARCH_LIMIT).get()) ?? [] | |
| 22 | ? (await User.where('name', 'like', `%${q}%`).get()) ?? [] | |
| 23 | : (await User.query().get()) ?? [] | |
| 23 | 24 | |
| 24 | 25 | const ids = users.map((u: any) => u.id) |
| 25 | 26 | const activities = ids.length ? (await Activity.whereIn('user_id', ids).get()) ?? [] : [] |
| @@ -46,7 +47,8 @@ export default new Action({ | ||
| 46 | 47 | // Discover mode surfaces the most active athletes first. |
| 47 | 48 | .sort((a: any, b: any) => b.activityCount - a.activityCount || b.followerCount - a.followerCount || a.id - b.id) |
| 48 | 49 | |
| 49 | return response.json({ success: true, athletes, meta: { total: athletes.length, query: q } }) | |
| 50 | const paged = paginate(athletes, readPageParams(request, { defaultLimit: 20, maxLimit: 50 })) | |
| 51 | return response.json({ success: true, athletes: paged.items, meta: { ...paged.meta, query: q } }) | |
| 50 | 52 | } |
| 51 | 53 | catch (error) { |
| 52 | 54 | console.error('[users] search failed:', error) |
| @@ -48,7 +48,10 @@ export default new Action({ | ||
| 48 | 48 | } |
| 49 | 49 | }) |
| 50 | 50 | |
| 51 | return response.json({ success: true, savedTrails, meta: { total: savedTrails.length } }) | |
| 51 | // Generous default — the profile Saved tab renders the full set, no | |
| 52 | // load-more (#978 review); ?limit/?offset still paginate on demand. | |
| 53 | const paged = paginate(savedTrails, readPageParams(request, { defaultLimit: 200, maxLimit: 200 })) | |
| 54 | return response.json({ success: true, savedTrails: paged.items, meta: paged.meta }) | |
| 52 | 55 | } |
| 53 | 56 | catch (error) { |
| 54 | 57 | console.error('[saved-trails] index failed:', error) |
| @@ -4,20 +4,23 @@ export default new Action({ | ||
| 4 | 4 | method: 'GET', |
| 5 | 5 | |
| 6 | 6 | async handle(request) { |
| 7 | const limit = Math.min(Math.max(request.get<number>('limit') || 500, 1), 500) | |
| 7 | const page = readPageParams(request, { defaultLimit: 500, maxLimit: 500 }) | |
| 8 | 8 | |
| 9 | 9 | try { |
| 10 | const rows = await Trail.limit(limit).get() | |
| 10 | const rows = await Trail.all() | |
| 11 | 11 | const trails = (rows ?? []).map((row: Record<string, unknown>) => ({ |
| 12 | 12 | ...row, |
| 13 | 13 | lat: row.latitude ?? row.lat, |
| 14 | 14 | lng: row.longitude ?? row.lng, |
| 15 | 15 | })) |
| 16 | return response.json(trails) | |
| 16 | // Wrapped (was a bare array, #978) — the catalog's extractApiTrailRows | |
| 17 | // already reads `.trails`, so this is backward-compatible. | |
| 18 | const { items, meta } = paginate(trails, page) | |
| 19 | return response.json({ success: true, trails: items, meta }) | |
| 17 | 20 | } |
| 18 | 21 | catch (error) { |
| 19 | 22 | console.error('[trails] index failed:', error) |
| 20 | return response.json([], 200) | |
| 23 | return response.json({ success: false, trails: [], meta: { offset: 0, limit: 0, total: 0, hasMore: false }, error: 'Failed to fetch trails' }, 500) | |
| 21 | 24 | } |
| 22 | 25 | }, |
| 23 | 26 | }) |
| @@ -36,7 +36,10 @@ export default new Action({ | ||
| 36 | 36 | createdAt: r.created_at, |
| 37 | 37 | })) |
| 38 | 38 | |
| 39 | return response.json({ success: true, reviews, meta: { total: reviews.length } }) | |
| 39 | // Generous default — the reviews tab renders the full set, no load-more | |
| 40 | // (#978 review); ?limit/?offset still paginate on demand. | |
| 41 | const paged = paginate(reviews, readPageParams(request, { defaultLimit: 200, maxLimit: 200 })) | |
| 42 | return response.json({ success: true, reviews: paged.items, meta: paged.meta }) | |
| 40 | 43 | } |
| 41 | 44 | catch (error) { |
| 42 | 45 | console.error('[trail-reviews] index failed:', error) |
| @@ -59,7 +59,7 @@ export default defineModel({ | ||
| 59 | 59 | order: 2, |
| 60 | 60 | fillable: true, |
| 61 | 61 | validation: { |
| 62 | rule: schema.number().required().min(0), | |
| 62 | rule: schema.float().required().min(0), | |
| 63 | 63 | message: { |
| 64 | 64 | required: 'Distance is required', |
| 65 | 65 | }, |
| @@ -113,7 +113,7 @@ export default defineModel({ | ||
| 113 | 113 | order: 5, |
| 114 | 114 | fillable: true, |
| 115 | 115 | validation: { |
| 116 | rule: schema.number().min(0), | |
| 116 | rule: schema.float().min(0), | |
| 117 | 117 | }, |
| 118 | 118 | factory: (faker) => faker.number.int({ min: 100, max: 3000 }), |
| 119 | 119 | }, |
| @@ -112,7 +112,7 @@ export default defineModel({ | ||
| 112 | 112 | order: 5, |
| 113 | 113 | fillable: true, |
| 114 | 114 | validation: { |
| 115 | rule: schema.number().required(), | |
| 115 | rule: schema.float().required(), | |
| 116 | 116 | message: { |
| 117 | 117 | required: 'Center latitude is required', |
| 118 | 118 | }, |
| @@ -124,7 +124,7 @@ export default defineModel({ | ||
| 124 | 124 | order: 6, |
| 125 | 125 | fillable: true, |
| 126 | 126 | validation: { |
| 127 | rule: schema.number().required(), | |
| 127 | rule: schema.float().required(), | |
| 128 | 128 | message: { |
| 129 | 129 | required: 'Center longitude is required', |
| 130 | 130 | }, |
| @@ -136,7 +136,7 @@ export default defineModel({ | ||
| 136 | 136 | order: 7, |
| 137 | 137 | fillable: true, |
| 138 | 138 | validation: { |
| 139 | rule: schema.number().required().min(0), | |
| 139 | rule: schema.float().required().min(0), | |
| 140 | 140 | message: { |
| 141 | 141 | required: 'Area size is required', |
| 142 | 142 | }, |
| @@ -149,7 +149,7 @@ export default defineModel({ | ||
| 149 | 149 | fillable: true, |
| 150 | 150 | nullable: true, |
| 151 | 151 | validation: { |
| 152 | rule: schema.number().min(0), | |
| 152 | rule: schema.float().min(0), | |
| 153 | 153 | }, |
| 154 | 154 | factory: (faker) => faker.number.float({ min: 100, max: 5000, fractionDigits: 2 }), |
| 155 | 155 | }, |
| @@ -75,7 +75,7 @@ export default defineModel({ | ||
| 75 | 75 | fillable: true, |
| 76 | 76 | nullable: true, |
| 77 | 77 | validation: { |
| 78 | rule: schema.number().min(0), | |
| 78 | rule: schema.float().min(0), | |
| 79 | 79 | }, |
| 80 | 80 | factory: (faker) => faker.number.float({ min: 1000, max: 500000, fractionDigits: 2 }), |
| 81 | 81 | }, |
| @@ -48,7 +48,7 @@ export default defineModel({ | ||
| 48 | 48 | order: 2, |
| 49 | 49 | fillable: true, |
| 50 | 50 | validation: { |
| 51 | rule: schema.number().min(0), | |
| 51 | rule: schema.float().min(0), | |
| 52 | 52 | }, |
| 53 | 53 | factory: (faker) => faker.number.float({ min: 0, max: 5000000, fractionDigits: 2 }), |
| 54 | 54 | }, |
| @@ -108,7 +108,7 @@ export default defineModel({ | ||
| 108 | 108 | order: 8, |
| 109 | 109 | fillable: true, |
| 110 | 110 | validation: { |
| 111 | rule: schema.number().min(0), | |
| 111 | rule: schema.float().min(0), | |
| 112 | 112 | }, |
| 113 | 113 | factory: (faker) => faker.number.float({ min: 0, max: 500000, fractionDigits: 2 }), |
| 114 | 114 | }, |
| @@ -57,7 +57,7 @@ export default defineModel({ | ||
| 57 | 57 | order: 3, |
| 58 | 58 | fillable: true, |
| 59 | 59 | validation: { |
| 60 | rule: schema.number().required().min(0), | |
| 60 | rule: schema.float().required().min(0), | |
| 61 | 61 | message: { |
| 62 | 62 | required: 'Distance is required', |
| 63 | 63 | min: 'Distance must be positive', |
| @@ -70,7 +70,7 @@ export default defineModel({ | ||
| 70 | 70 | order: 4, |
| 71 | 71 | fillable: true, |
| 72 | 72 | validation: { |
| 73 | rule: schema.number().required().min(0), | |
| 73 | rule: schema.float().required().min(0), | |
| 74 | 74 | message: { |
| 75 | 75 | required: 'Elevation is required', |
| 76 | 76 | }, |
| @@ -94,7 +94,7 @@ export default defineModel({ | ||
| 94 | 94 | order: 6, |
| 95 | 95 | fillable: true, |
| 96 | 96 | validation: { |
| 97 | rule: schema.number().min(0).max(5), | |
| 97 | rule: schema.float().min(0).max(5), | |
| 98 | 98 | }, |
| 99 | 99 | factory: (faker) => faker.number.float({ min: 3.5, max: 5, fractionDigits: 1 }), |
| 100 | 100 | }, |
| @@ -143,7 +143,7 @@ export default defineModel({ | ||
| 143 | 143 | order: 11, |
| 144 | 144 | fillable: true, |
| 145 | 145 | validation: { |
| 146 | rule: schema.number(), | |
| 146 | rule: schema.float(), | |
| 147 | 147 | }, |
| 148 | 148 | factory: (faker) => faker.location.latitude(), |
| 149 | 149 | }, |
| @@ -152,7 +152,7 @@ export default defineModel({ | ||
| 152 | 152 | order: 12, |
| 153 | 153 | fillable: true, |
| 154 | 154 | validation: { |
| 155 | rule: schema.number(), | |
| 155 | rule: schema.float(), | |
| 156 | 156 | }, |
| 157 | 157 | factory: (faker) => faker.location.longitude(), |
| 158 | 158 | }, |
| @@ -23,7 +23,7 @@ export default defineModel({ | ||
| 23 | 23 | order: 1, |
| 24 | 24 | fillable: true, |
| 25 | 25 | validation: { |
| 26 | rule: schema.number().min(0), | |
| 26 | rule: schema.float().min(0), | |
| 27 | 27 | }, |
| 28 | 28 | factory: (faker) => faker.number.float({ min: 10, max: 2000, fractionDigits: 1 }), |
| 29 | 29 | }, |
| @@ -45,7 +45,7 @@ export default defineModel({ | ||
| 45 | 45 | order: 3, |
| 46 | 46 | fillable: true, |
| 47 | 47 | validation: { |
| 48 | rule: schema.number().min(0), | |
| 48 | rule: schema.float().min(0), | |
| 49 | 49 | }, |
| 50 | 50 | factory: (faker) => faker.number.int({ min: 5000, max: 500000 }), |
| 51 | 51 | }, |
| @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS "challenges" ( | ||
| 3 | 3 | "challenger_id" INTEGER, |
| 4 | 4 | "challenged_id" INTEGER, |
| 5 | 5 | "territory_id" INTEGER REFERENCES "territories"("id"), |
| 6 | "area_at_stake" INTEGER, | |
| 6 | "area_at_stake" REAL, | |
| 7 | 7 | "status" TEXT CHECK ("status" IN ('pending', 'active', 'completed', 'declined')), |
| 8 | 8 | "winner_id" INTEGER, |
| 9 | 9 | "deadline" TEXT, |
| @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "territory_histories" ( | ||
| 5 | 5 | "activity_id" INTEGER REFERENCES "activities"("id"), |
| 6 | 6 | "previous_owner_id" INTEGER, |
| 7 | 7 | "event_type" TEXT CHECK ("event_type" IN ('claimed', 'conquered', 'split', 'defended', 'contested', 'expired')), |
| 8 | "area_at_event" INTEGER, | |
| 8 | "area_at_event" REAL, | |
| 9 | 9 | "previous_ownership_duration" INTEGER, |
| 10 | 10 | "notes" TEXT, |
| 11 | 11 | "new_territory_id" INTEGER, |
| @@ -2,16 +2,16 @@ CREATE TABLE IF NOT EXISTS "trails" ( | ||
| 2 | 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, |
| 3 | 3 | "name" TEXT, |
| 4 | 4 | "location" TEXT, |
| 5 | "distance" INTEGER, | |
| 6 | "elevation" INTEGER, | |
| 5 | "distance" REAL, | |
| 6 | "elevation" REAL, | |
| 7 | 7 | "difficulty" TEXT CHECK ("difficulty" IN ('easy', 'moderate', 'hard')), |
| 8 | "rating" INTEGER, | |
| 8 | "rating" REAL, | |
| 9 | 9 | "review_count" INTEGER, |
| 10 | 10 | "estimated_time" TEXT, |
| 11 | 11 | "image" TEXT, |
| 12 | 12 | "tags" TEXT, |
| 13 | "latitude" INTEGER, | |
| 14 | "longitude" INTEGER, | |
| 13 | "latitude" REAL, | |
| 14 | "longitude" REAL, | |
| 15 | 15 | "description" TEXT, |
| 16 | 16 | "geometry" TEXT, |
| 17 | 17 | "created_at" TEXT not null default CURRENT_TIMESTAMP, |
| @@ -3,11 +3,11 @@ CREATE TABLE IF NOT EXISTS "activities" ( | ||
| 3 | 3 | "user_id" INTEGER REFERENCES "users"("id"), |
| 4 | 4 | "trail_id" INTEGER REFERENCES "trails"("id"), |
| 5 | 5 | "activity_type" TEXT CHECK ("activity_type" IN ('Trail Run', 'Hike', 'Walk', 'Bike')), |
| 6 | "distance" INTEGER, | |
| 6 | "distance" REAL, | |
| 7 | 7 | "duration" TEXT, |
| 8 | 8 | "moving_time" TEXT, |
| 9 | 9 | "pace" TEXT, |
| 10 | "elevation" INTEGER, | |
| 10 | "elevation" REAL, | |
| 11 | 11 | "kudos_count" INTEGER, |
| 12 | 12 | "notes" TEXT, |
| 13 | 13 | "gpx_data" TEXT, |
| @@ -6,10 +6,10 @@ CREATE TABLE IF NOT EXISTS "territories" ( | ||
| 6 | 6 | "name" TEXT, |
| 7 | 7 | "polygon_data" TEXT, |
| 8 | 8 | "bounding_box" TEXT, |
| 9 | "center_lat" INTEGER, | |
| 10 | "center_lng" INTEGER, | |
| 11 | "area_size" INTEGER, | |
| 12 | "perimeter" INTEGER, | |
| 9 | "center_lat" REAL, | |
| 10 | "center_lng" REAL, | |
| 11 | "area_size" REAL, | |
| 12 | "perimeter" REAL, | |
| 13 | 13 | "status" TEXT CHECK ("status" IN ('active', 'contested', 'expired')), |
| 14 | 14 | "conquest_count" INTEGER, |
| 15 | 15 | "claimed_at" TEXT, |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | CREATE TABLE IF NOT EXISTS "user_stats" ( |
| 2 | 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, |
| 3 | "total_distance" INTEGER, | |
| 3 | "total_distance" REAL, | |
| 4 | 4 | "total_time" TEXT, |
| 5 | "total_elevation" INTEGER, | |
| 5 | "total_elevation" REAL, | |
| 6 | 6 | "trails_completed" INTEGER, |
| 7 | 7 | "current_streak" INTEGER, |
| 8 | 8 | "longest_streak" INTEGER, |
| @@ -2,13 +2,13 @@ CREATE TABLE IF NOT EXISTS "territory_stats" ( | ||
| 2 | 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, |
| 3 | 3 | "user_id" INTEGER REFERENCES "users"("id"), |
| 4 | 4 | "total_territories_owned" INTEGER, |
| 5 | "total_area_owned" INTEGER, | |
| 5 | "total_area_owned" REAL, | |
| 6 | 6 | "territories_claimed" INTEGER, |
| 7 | 7 | "territories_conquered" INTEGER, |
| 8 | 8 | "territories_lost" INTEGER, |
| 9 | 9 | "territories_defended" INTEGER, |
| 10 | 10 | "longest_ownership_days" INTEGER, |
| 11 | "largest_territory_area" INTEGER, | |
| 11 | "largest_territory_area" REAL, | |
| 12 | 12 | "weekly_rank" INTEGER, |
| 13 | 13 | "all_time_rank" INTEGER, |
| 14 | 14 | "created_at" TEXT not null default CURRENT_TIMESTAMP, |
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * Shared list-endpoint pagination (#978). Read `offset`/`limit` from the | |
| 3 | * request (clamped), then slice a fully-built result array and emit a | |
| 4 | * consistent `meta: { offset, limit, total, hasMore }`. Auto-imported into the | |
| 5 | * index actions. | |
| 6 | * | |
| 7 | * The actions build + enrich their full result set (joined names, derived | |
| 8 | * stats) and paginate the mapped array, so `total` reflects the real match | |
| 9 | * count regardless of the page. Fine for this app's dataset sizes; for very | |
| 10 | * large tables a DB-level offset/limit + count would be the next step. | |
| 11 | */ | |
| 12 | ||
| 13 | export interface PageParams { offset: number, limit: number } | |
| 14 | export interface PageMeta { offset: number, limit: number, total: number, hasMore: boolean } | |
| 15 | ||
| 16 | export function readPageParams( | |
| 17 | request: { get: (k: string) => any }, | |
| 18 | opts: { defaultLimit?: number, maxLimit?: number } = {}, | |
| 19 | ): PageParams { | |
| 20 | const defaultLimit = opts.defaultLimit ?? 50 | |
| 21 | const maxLimit = opts.maxLimit ?? 200 | |
| 22 | const rawLimit = Number(request.get('limit')) | |
| 23 | const rawOffset = Number(request.get('offset')) | |
| 24 | const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), maxLimit) : defaultLimit | |
| 25 | const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0 | |
| 26 | return { offset, limit } | |
| 27 | } | |
| 28 | ||
| 29 | export function paginate<T>(rows: T[], page: PageParams): { items: T[], meta: PageMeta } { | |
| 30 | const total = rows.length | |
| 31 | const items = rows.slice(page.offset, page.offset + page.limit) | |
| 32 | return { | |
| 33 | items, | |
| 34 | meta: { offset: page.offset, limit: page.limit, total, hasMore: page.offset + page.limit < total }, | |
| 35 | } | |
| 36 | } | |
| @@ -99,13 +99,13 @@ | ||
| 99 | 99 | <div class="card mb-6"> |
| 100 | 100 | <div class="flex items-center justify-between mb-4"> |
| 101 | 101 | <div class="flex items-center gap-3"> |
| 102 | <div class="w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 text-lg font-bold" :text="activity.userName.charAt(0)"></div> | |
| 102 | <div class="w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 text-lg font-bold">{{ activity.userName.charAt(0) }}</div> | |
| 103 | 103 | <div> |
| 104 | <p class="font-bold dark:text-white" :text="activity.userName"></p> | |
| 104 | <p class="font-bold dark:text-white">{{ activity.userName }}</p> | |
| 105 | 105 | <p class="text-sm text-gray-500 dark:text-slate-400"> |
| 106 | <span :text="activity.activityType"></span> | |
| 106 | <span>{{ activity.activityType }}</span> | |
| 107 | 107 | <span class="mx-1">·</span> |
| 108 | <span :text="tb.timeAgo(activity.created_at)"></span> | |
| 108 | <span>{{ tb.timeAgo(activity.created_at) }}</span> | |
| 109 | 109 | </p> |
| 110 | 110 | </div> |
| 111 | 111 | </div> |
| @@ -113,7 +113,7 @@ | ||
| 113 | 113 | <button |
| 114 | 114 | @click="editing ? closeEditor() : openEditor(activity)" |
| 115 | 115 | class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-gray-200 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-emerald-500 hover:text-emerald-600 transition" |
| 116 | :text="editing ? 'Close' : 'Edit'"></button> | |
| 116 | >{{ editing ? 'Close' : 'Edit' }}</button> | |
| 117 | 117 | <button |
| 118 | 118 | :if="confirmingDelete" |
| 119 | 119 | @click="cancelDelete()" |
| @@ -122,14 +122,14 @@ | ||
| 122 | 122 | @click="deleteRun(activity)" |
| 123 | 123 | x-class="confirmingDelete ? 'bg-red-600 text-white border-red-600' : 'border-gray-200 dark:border-slate-600 text-red-500 hover:border-red-400'" |
| 124 | 124 | class="px-3 py-1.5 text-xs font-semibold rounded-lg border transition" |
| 125 | :text="confirmingDelete ? 'Confirm delete' : 'Delete'"></button> | |
| 125 | >{{ confirmingDelete ? 'Confirm delete' : 'Delete' }}</button> | |
| 126 | 126 | </div> |
| 127 | 127 | </div> |
| 128 | 128 | |
| 129 | <h1 class="text-2xl font-bold dark:text-white mb-2" :text="activity.title"></h1> | |
| 130 | <StxLink :if="activity.trail_id" x-to="'/trail/' + activity.trail_id" class="text-sm text-emerald-600 hover:underline" :text="'→ ' + activity.trail_name"></StxLink> | |
| 131 | <p :if="activity.notes && !editing" class="mt-3 text-sm text-gray-600 dark:text-slate-300" :text="activity.notes"></p> | |
| 132 | <p :if="editorError && !editing" class="mt-2 text-sm text-red-500" :text="editorError"></p> | |
| 129 | <h1 class="text-2xl font-bold dark:text-white mb-2">{{ activity.title }}</h1> | |
| 130 | <StxLink :if="activity.trail_id" x-to="'/trail/' + activity.trail_id" class="text-sm text-emerald-600 hover:underline">{{ '→ ' + activity.trail_name }}</StxLink> | |
| 131 | <p :if="activity.notes && !editing" class="mt-3 text-sm text-gray-600 dark:text-slate-300">{{ activity.notes }}</p> | |
| 132 | <p :if="editorError && !editing" class="mt-2 text-sm text-red-500">{{ editorError }}</p> | |
| 133 | 133 | |
| 134 | 134 | <!-- Owner edit panel (#954) --> |
| 135 | 135 | <div :if="editing" class="mt-4 pt-4 border-t border-gray-100 dark:border-slate-700 space-y-3"> |
| @@ -161,9 +161,9 @@ | ||
| 161 | 161 | <textarea rows="2" x-value="formNotes" @input="e => formNotes.set(e.target.value)" placeholder="How did it go?" class="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm"></textarea> |
| 162 | 162 | </div> |
| 163 | 163 | <p :if="activity.hasGps" class="text-xs text-gray-400">Distance, time and elevation come from the GPS track and can't be edited.</p> |
| 164 | <p :if="editorError" class="text-sm text-red-500" :text="editorError"></p> | |
| 164 | <p :if="editorError" class="text-sm text-red-500">{{ editorError }}</p> | |
| 165 | 165 | <div class="flex gap-2"> |
| 166 | <button @click="saveEdit(activity)" class="px-4 py-2 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition" :text="saving ? 'Saving…' : 'Save changes'"></button> | |
| 166 | <button @click="saveEdit(activity)" class="px-4 py-2 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition">{{ saving ? 'Saving…' : 'Save changes' }}</button> | |
| 167 | 167 | <button @click="closeEditor()" class="px-4 py-2 text-sm font-medium text-gray-500 dark:text-slate-400 hover:text-gray-700 transition">Cancel</button> |
| 168 | 168 | </div> |
| 169 | 169 | </div> |
| @@ -177,28 +177,28 @@ | ||
| 177 | 177 | <!-- Stats --> |
| 178 | 178 | <div class="grid grid-cols-3 sm:grid-cols-6 gap-4 mb-6"> |
| 179 | 179 | <div class="card text-center"> |
| 180 | <p class="text-xl font-bold text-emerald-600" :text="activity.distance.toFixed(1)"></p> | |
| 180 | <p class="text-xl font-bold text-emerald-600">{{ activity.distance.toFixed(1) }}</p> | |
| 181 | 181 | <p class="metric-label">Miles</p> |
| 182 | 182 | </div> |
| 183 | 183 | <div class="card text-center"> |
| 184 | <p class="text-xl font-bold dark:text-white" :text="activity.moving_time || activity.duration"></p> | |
| 184 | <p class="text-xl font-bold dark:text-white">{{ activity.moving_time || activity.duration }}</p> | |
| 185 | 185 | <p class="metric-label">Moving Time</p> |
| 186 | <p :if="activity.moving_time && activity.moving_time !== activity.duration" class="text-xs text-gray-400 mt-1" :text="'Elapsed ' + activity.duration"></p> | |
| 186 | <p :if="activity.moving_time && activity.moving_time !== activity.duration" class="text-xs text-gray-400 mt-1">{{ 'Elapsed ' + activity.duration }}</p> | |
| 187 | 187 | </div> |
| 188 | 188 | <div class="card text-center"> |
| 189 | <p class="text-xl font-bold dark:text-white" :text="activity.pace"></p> | |
| 189 | <p class="text-xl font-bold dark:text-white">{{ activity.pace }}</p> | |
| 190 | 190 | <p class="metric-label">Pace</p> |
| 191 | 191 | </div> |
| 192 | 192 | <div class="card text-center"> |
| 193 | <p class="text-xl font-bold text-blue-600" :text="activity.elevation_gain.toLocaleString()"></p> | |
| 193 | <p class="text-xl font-bold text-blue-600">{{ activity.elevation_gain.toLocaleString() }}</p> | |
| 194 | 194 | <p class="metric-label">Elev (ft)</p> |
| 195 | 195 | </div> |
| 196 | 196 | <div class="card text-center"> |
| 197 | <p class="text-xl font-bold text-orange-600" :text="activity.calories || 0"></p> | |
| 197 | <p class="text-xl font-bold text-orange-600">{{ activity.calories || 0 }}</p> | |
| 198 | 198 | <p class="metric-label">Calories</p> |
| 199 | 199 | </div> |
| 200 | 200 | <div class="card text-center"> |
| 201 | <p class="text-xl font-bold text-red-500" :text="activity.heartRateAvg || '--'"></p> | |
| 201 | <p class="text-xl font-bold text-red-500">{{ activity.heartRateAvg || '--' }}</p> | |
| 202 | 202 | <p class="metric-label">Avg HR</p> |
| 203 | 203 | </div> |
| 204 | 204 | </div> |
| @@ -209,9 +209,9 @@ | ||
| 209 | 209 | <div class="space-y-2"> |
| 210 | 210 | <template :for="split in activity.splits"> |
| 211 | 211 | <div class="grid grid-cols-3 gap-2 items-center p-3 rounded-lg"> |
| 212 | <span class="text-sm font-medium dark:text-white" :text="'Mile ' + split.mile"></span> | |
| 213 | <span class="text-center text-sm font-semibold dark:text-white" :text="split.pace"></span> | |
| 214 | <span class="text-right text-sm text-gray-600 dark:text-slate-400" :text="split.elev + ' ft'"></span> | |
| 212 | <span class="text-sm font-medium dark:text-white">{{ 'Mile ' + split.mile }}</span> | |
| 213 | <span class="text-center text-sm font-semibold dark:text-white">{{ split.pace }}</span> | |
| 214 | <span class="text-right text-sm text-gray-600 dark:text-slate-400">{{ split.elev + ' ft' }}</span> | |
| 215 | 215 | </div> |
| 216 | 216 | </template> |
| 217 | 217 | </div> |
| @@ -222,11 +222,11 @@ | ||
| 222 | 222 | <div class="flex items-center gap-6"> |
| 223 | 223 | <button @click="handlers.toggleKudos()" class="flex items-center gap-2 transition" x-class="hasKudos ? 'text-red-500' : 'text-gray-500 hover:text-red-500'"> |
| 224 | 224 | <Icon name="heart" size="18" /> |
| 225 | <span class="font-medium" x-text="activity.kudos_count + ' Kudos'"></span> | |
| 225 | <span class="font-medium">{{ activity.kudos_count + ' Kudos' }}</span> | |
| 226 | 226 | </button> |
| 227 | 227 | <span class="flex items-center gap-2 text-gray-500"> |
| 228 | 228 | <Icon name="message-circle" size="18" /> |
| 229 | <span class="font-medium" x-text="comments.length + ' Comments'"></span> | |
| 229 | <span class="font-medium">{{ comments.length + ' Comments' }}</span> | |
| 230 | 230 | </span> |
| 231 | 231 | </div> |
| 232 | 232 | </div> |
| @@ -237,16 +237,16 @@ | ||
| 237 | 237 | <div class="space-y-4 mb-4"> |
| 238 | 238 | <template :for="comment in comments"> |
| 239 | 239 | <div class="flex gap-3"> |
| 240 | <div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-slate-700 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-slate-300 shrink-0" :text="comment.userName.charAt(0)"></div> | |
| 240 | <div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-slate-700 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-slate-300 shrink-0">{{ comment.userName.charAt(0) }}</div> | |
| 241 | 241 | <div class="flex-1"> |
| 242 | 242 | <div class="flex items-center gap-2 mb-1"> |
| 243 | <span class="text-sm font-semibold dark:text-white" :text="comment.userName"></span> | |
| 244 | <span class="text-xs text-gray-400" :text="tb.timeAgo(comment.createdAt)"></span> | |
| 243 | <span class="text-sm font-semibold dark:text-white">{{ comment.userName }}</span> | |
| 244 | <span class="text-xs text-gray-400">{{ tb.timeAgo(comment.createdAt) }}</span> | |
| 245 | 245 | </div> |
| 246 | 246 | <p class="text-sm text-gray-600 dark:text-slate-400"> |
| 247 | 247 | <template :for="seg in mentionSegments(comment.body)"> |
| 248 | <StxLink :if="seg.userId" x-to="'/athlete/' + seg.userId" class="text-emerald-600 font-medium hover:underline" :text="seg.text"></StxLink> | |
| 249 | <span :if="!seg.userId" :text="seg.text"></span> | |
| 248 | <StxLink :if="seg.userId" x-to="'/athlete/' + seg.userId" class="text-emerald-600 font-medium hover:underline">{{ seg.text }}</StxLink> | |
| 249 | <span :if="!seg.userId">{{ seg.text }}</span> | |
| 250 | 250 | </template> |
| 251 | 251 | </p> |
| 252 | 252 | </div> |
| @@ -264,7 +264,7 @@ | ||
| 264 | 264 | <button |
| 265 | 265 | @click="pickMention(candidate.name)" |
| 266 | 266 | class="px-3 py-1.5 text-xs font-medium rounded-full bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/40 hover:bg-emerald-100 transition" |
| 267 | :text="'@' + candidate.name"></button> | |
| 267 | >{{ '@' + candidate.name }}</button> | |
| 268 | 268 | </template> |
| 269 | 269 | </div> |
| 270 | 270 | <div class="flex gap-3"> |
| @@ -76,40 +76,40 @@ | ||
| 76 | 76 | <!-- Header --> |
| 77 | 77 | <div class="mb-6 card"> |
| 78 | 78 | <div class="flex flex-col gap-6 items-center sm:flex-row"> |
| 79 | <div class="flex items-center justify-center h-24 w-24 font-bold text-3xl text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 rounded-full" :text="(profile.user.name || 'U').charAt(0)"></div> | |
| 79 | <div class="flex items-center justify-center h-24 w-24 font-bold text-3xl text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">{{ (profile.user.name || 'U').charAt(0) }}</div> | |
| 80 | 80 | <div class="flex-1 text-center sm:text-left"> |
| 81 | 81 | <div class="flex flex-col sm:flex-row sm:items-center gap-3 justify-center sm:justify-start"> |
| 82 | <h1 class="font-bold text-2xl dark:text-white" :text="profile.user.name"></h1> | |
| 82 | <h1 class="font-bold text-2xl dark:text-white">{{ profile.user.name }}</h1> | |
| 83 | 83 | <button |
| 84 | 84 | :if="!isSelf" |
| 85 | 85 | @click="onFollow()" |
| 86 | 86 | x-class="isFollowing ? 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'" |
| 87 | 87 | class="px-4 py-1.5 font-semibold text-sm rounded-lg transition" |
| 88 | :text="isFollowing ? 'Following' : 'Follow'"></button> | |
| 88 | >{{ isFollowing ? 'Following' : 'Follow' }}</button> | |
| 89 | 89 | </div> |
| 90 | 90 | <div class="flex flex-wrap gap-4 justify-center sm:justify-start mt-3"> |
| 91 | 91 | <div class="text-center"> |
| 92 | <p class="text-lg font-bold text-emerald-600" :text="profile.stats.totalDistance.toFixed(1)"></p> | |
| 92 | <p class="text-lg font-bold text-emerald-600">{{ profile.stats.totalDistance.toFixed(1) }}</p> | |
| 93 | 93 | <p class="metric-label">Miles</p> |
| 94 | 94 | </div> |
| 95 | 95 | <div class="text-center"> |
| 96 | <p class="text-lg font-bold text-emerald-600" :text="profile.stats.activityCount"></p> | |
| 96 | <p class="text-lg font-bold text-emerald-600">{{ profile.stats.activityCount }}</p> | |
| 97 | 97 | <p class="metric-label">Activities</p> |
| 98 | 98 | </div> |
| 99 | 99 | <div class="text-center"> |
| 100 | <p class="text-lg font-bold text-emerald-600" :text="profile.stats.totalElevation.toLocaleString()"></p> | |
| 100 | <p class="text-lg font-bold text-emerald-600">{{ profile.stats.totalElevation.toLocaleString() }}</p> | |
| 101 | 101 | <p class="metric-label">Elev (ft)</p> |
| 102 | 102 | </div> |
| 103 | 103 | <div class="text-center"> |
| 104 | <p class="text-lg font-bold text-emerald-600" :text="profile.stats.territoriesOwned"></p> | |
| 104 | <p class="text-lg font-bold text-emerald-600">{{ profile.stats.territoriesOwned }}</p> | |
| 105 | 105 | <p class="metric-label">Territories</p> |
| 106 | 106 | </div> |
| 107 | 107 | <div class="text-center"> |
| 108 | <p class="text-lg font-bold text-emerald-600" :text="profile.followerCount"></p> | |
| 108 | <p class="text-lg font-bold text-emerald-600">{{ profile.followerCount }}</p> | |
| 109 | 109 | <p class="metric-label">Followers</p> |
| 110 | 110 | </div> |
| 111 | 111 | <div class="text-center"> |
| 112 | <p class="text-lg font-bold text-emerald-600" :text="profile.followingCount"></p> | |
| 112 | <p class="text-lg font-bold text-emerald-600">{{ profile.followingCount }}</p> | |
| 113 | 113 | <p class="metric-label">Following</p> |
| 114 | 114 | </div> |
| 115 | 115 | </div> |
| @@ -125,14 +125,14 @@ | ||
| 125 | 125 | <StxLink x-to="'/activity/' + a.id" class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-slate-700 last:border-0"> |
| 126 | 126 | <div> |
| 127 | 127 | <p class="font-semibold text-sm dark:text-white"> |
| 128 | <span :text="a.activityType"></span> | |
| 129 | <span :if="a.trailName" class="text-gray-500 dark:text-slate-400"> · <span :text="a.trailName"></span></span> | |
| 128 | <span>{{ a.activityType }}</span> | |
| 129 | <span :if="a.trailName" class="text-gray-500 dark:text-slate-400"> · <span>{{ a.trailName }}</span></span> | |
| 130 | 130 | </p> |
| 131 | <p class="text-gray-400 text-xs" :text="tb.timeAgo(a.completedAt)"></p> | |
| 131 | <p class="text-gray-400 text-xs">{{ tb.timeAgo(a.completedAt) }}</p> | |
| 132 | 132 | </div> |
| 133 | 133 | <div class="text-right"> |
| 134 | <p class="font-bold text-emerald-600 text-sm" :text="a.distance.toFixed(1) + ' mi'"></p> | |
| 135 | <p class="text-gray-400 text-xs" :text="a.elevationGain.toLocaleString() + ' ft'"></p> | |
| 134 | <p class="font-bold text-emerald-600 text-sm">{{ a.distance.toFixed(1) + ' mi' }}</p> | |
| 135 | <p class="text-gray-400 text-xs">{{ a.elevationGain.toLocaleString() + ' ft' }}</p> | |
| 136 | 136 | </div> |
| 137 | 137 | </StxLink> |
| 138 | 138 | </template> |
| @@ -93,7 +93,7 @@ | ||
| 93 | 93 | <Icon name="alert-triangle" size="28" class="text-red-400" /> |
| 94 | 94 | </div> |
| 95 | 95 | <h3 class="text-base font-semibold text-gray-900 dark:text-white mb-1">Couldn't load athletes</h3> |
| 96 | <p class="text-sm text-gray-500 dark:text-slate-400 mb-4" :text="error"></p> | |
| 96 | <p class="text-sm text-gray-500 dark:text-slate-400 mb-4">{{ error }}</p> | |
| 97 | 97 | <button @click="retrySearch()" class="px-5 py-2 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition">Try again</button> |
| 98 | 98 | </div> |
| 99 | 99 | |
| @@ -101,15 +101,15 @@ | ||
| 101 | 101 | <template :for="athlete in athletes"> |
| 102 | 102 | <div class="card flex items-center gap-4"> |
| 103 | 103 | <StxLink x-to="'/athlete/' + athlete.id" class="flex items-center gap-3 flex-1 min-w-0 group"> |
| 104 | <div class="w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 text-lg font-bold shrink-0" :text="athlete.name.charAt(0)"></div> | |
| 104 | <div class="w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 text-lg font-bold shrink-0">{{ athlete.name.charAt(0) }}</div> | |
| 105 | 105 | <div class="min-w-0"> |
| 106 | <p class="font-bold dark:text-white group-hover:text-emerald-600 transition truncate" :text="athlete.name"></p> | |
| 106 | <p class="font-bold dark:text-white group-hover:text-emerald-600 transition truncate">{{ athlete.name }}</p> | |
| 107 | 107 | <p class="text-xs text-gray-500 dark:text-slate-400"> |
| 108 | <span :text="athlete.activityCount + ' activities'"></span> | |
| 108 | <span>{{ athlete.activityCount + ' activities' }}</span> | |
| 109 | 109 | <span class="mx-1">·</span> |
| 110 | <span :text="athlete.followerCount + ' followers'"></span> | |
| 110 | <span>{{ athlete.followerCount + ' followers' }}</span> | |
| 111 | 111 | <span :if="athlete.territoriesOwned > 0" class="mx-1">·</span> |
| 112 | <span :if="athlete.territoriesOwned > 0" :text="athlete.territoriesOwned + ' territories (' + areaKm2(athlete.totalAreaOwned) + ' km²)'"></span> | |
| 112 | <span :if="athlete.territoriesOwned > 0">{{ athlete.territoriesOwned + ' territories (' + areaKm2(athlete.totalAreaOwned) + ' km²)' }}</span> | |
| 113 | 113 | </p> |
| 114 | 114 | </div> |
| 115 | 115 | </StxLink> |
| @@ -118,7 +118,7 @@ | ||
| 118 | 118 | @click="onFollow(athlete.id)" |
| 119 | 119 | x-class="tb.isFollowing(athlete.id) ? 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'" |
| 120 | 120 | class="px-4 py-2 font-semibold text-xs rounded-lg transition shrink-0" |
| 121 | :text="tb.isFollowing(athlete.id) ? 'Following' : 'Follow'"></button> | |
| 121 | >{{ tb.isFollowing(athlete.id) ? 'Following' : 'Follow' }}</button> | |
| 122 | 122 | <span :if="tb && athlete.id === tb.currentUserId()" class="px-3 py-2 text-xs text-gray-400 shrink-0">That's you</span> |
| 123 | 123 | </div> |
| 124 | 124 | </template> |
| @@ -128,7 +128,7 @@ | ||
| 128 | 128 | <Icon name="search" size="28" class="text-emerald-400" /> |
| 129 | 129 | </div> |
| 130 | 130 | <h3 class="text-base font-semibold text-gray-900 dark:text-white mb-1">No athletes found</h3> |
| 131 | <p class="text-sm text-gray-500 dark:text-slate-400" x-text="'Nothing matched “' + query + '” — try another name.'"></p> | |
| 131 | <p class="text-sm text-gray-500 dark:text-slate-400">{{ 'Nothing matched “' + query + '” — try another name.' }}</p> | |
| 132 | 132 | </div> |
| 133 | 133 | </div> |
| 134 | 134 | </div> |
| @@ -55,9 +55,9 @@ | ||
| 55 | 55 | <div class="bg-orange-50/50 dark:bg-orange-900/5 border-2 border-orange-200 dark:border-orange-800/50 card"> |
| 56 | 56 | <div class="flex gap-4 items-center"> |
| 57 | 57 | <div class="flex flex-1 gap-2 items-center"> |
| 58 | <div class="flex items-center justify-center h-10 w-10 font-bold text-orange-600 text-sm bg-orange-100 dark:bg-orange-900/30 rounded-full" :text="battle.attackerName.charAt(0)"></div> | |
| 58 | <div class="flex items-center justify-center h-10 w-10 font-bold text-orange-600 text-sm bg-orange-100 dark:bg-orange-900/30 rounded-full">{{ battle.attackerName.charAt(0) }}</div> | |
| 59 | 59 | <div> |
| 60 | <p class="font-semibold text-sm dark:text-white" :text="battle.attackerName"></p> | |
| 60 | <p class="font-semibold text-sm dark:text-white">{{ battle.attackerName }}</p> | |
| 61 | 61 | <p class="text-orange-600 text-xs">Attacking</p> |
| 62 | 62 | </div> |
| 63 | 63 | </div> |
| @@ -66,14 +66,14 @@ | ||
| 66 | 66 | </div> |
| 67 | 67 | <div class="flex flex-1 gap-2 items-center justify-end text-right"> |
| 68 | 68 | <div> |
| 69 | <p class="font-semibold text-sm dark:text-white" :text="battle.defenderName"></p> | |
| 69 | <p class="font-semibold text-sm dark:text-white">{{ battle.defenderName }}</p> | |
| 70 | 70 | <p class="text-blue-600 text-xs">Defending</p> |
| 71 | 71 | </div> |
| 72 | <div class="flex items-center justify-center h-10 w-10 font-bold text-blue-600 text-sm bg-blue-100 dark:bg-blue-900/30 rounded-full" :text="battle.defenderName.charAt(0)"></div> | |
| 72 | <div class="flex items-center justify-center h-10 w-10 font-bold text-blue-600 text-sm bg-blue-100 dark:bg-blue-900/30 rounded-full">{{ battle.defenderName.charAt(0) }}</div> | |
| 73 | 73 | </div> |
| 74 | 74 | </div> |
| 75 | 75 | <div class="mt-3 text-xs text-center"> |
| 76 | <StxLink x-to="'/territory/' + battle.territory_id" class="text-emerald-600 hover:underline font-medium" :text="battle.territoryName"></StxLink> | |
| 76 | <StxLink x-to="'/territory/' + battle.territory_id" class="text-emerald-600 hover:underline font-medium">{{ battle.territoryName }}</StxLink> | |
| 77 | 77 | </div> |
| 78 | 78 | </div> |
| 79 | 79 | </template> |
| @@ -99,18 +99,18 @@ | ||
| 99 | 99 | </div> |
| 100 | 100 | <div class="flex-1 min-w-0"> |
| 101 | 101 | <p class="text-sm text-gray-900 dark:text-white"> |
| 102 | <span class="font-semibold" :text="battle.attackerName"></span> | |
| 103 | <span :text="battle.status === 'conquered' ? ' conquered ' : ' failed to conquer '"></span> | |
| 104 | <StxLink x-to="'/territory/' + battle.territory_id" class="font-semibold text-emerald-600 hover:underline" :text="battle.territoryName"></StxLink> | |
| 102 | <span class="font-semibold">{{ battle.attackerName }}</span> | |
| 103 | <span>{{ battle.status === 'conquered' ? ' conquered ' : ' failed to conquer ' }}</span> | |
| 104 | <StxLink x-to="'/territory/' + battle.territory_id" class="font-semibold text-emerald-600 hover:underline">{{ battle.territoryName }}</StxLink> | |
| 105 | 105 | <span> from </span> |
| 106 | <span class="font-semibold" :text="battle.defenderName"></span> | |
| 106 | <span class="font-semibold">{{ battle.defenderName }}</span> | |
| 107 | 107 | </p> |
| 108 | 108 | <div class="flex gap-3 mt-1 text-xs text-gray-500 dark:text-slate-400"> |
| 109 | <span :text="tb.timeAgo(battle.created_at)"></span> | |
| 110 | <span :text="battle.areaCaptured.toLocaleString() + ' m²'"></span> | |
| 109 | <span>{{ tb.timeAgo(battle.created_at) }}</span> | |
| 110 | <span>{{ battle.areaCaptured.toLocaleString() + ' m²' }}</span> | |
| 111 | 111 | </div> |
| 112 | 112 | </div> |
| 113 | <span class="shrink-0 text-xs px-2 py-1 rounded-full font-medium" x-class="battle.status === 'conquered' ? 'bg-orange-100 text-orange-700' : 'bg-emerald-100 text-emerald-700'" :text="battle.status === 'conquered' ? 'Conquered' : 'Defended'"></span> | |
| 113 | <span class="shrink-0 text-xs px-2 py-1 rounded-full font-medium" x-class="battle.status === 'conquered' ? 'bg-orange-100 text-orange-700' : 'bg-emerald-100 text-emerald-700'">{{ battle.status === 'conquered' ? 'Conquered' : 'Defended' }}</span> | |
| 114 | 114 | </div> |
| 115 | 115 | </template> |
| 116 | 116 | |
| @@ -130,11 +130,11 @@ | ||
| 130 | 130 | <h3 class="font-bold text-lg text-gray-900 dark:text-white mb-4">Battle Stats</h3> |
| 131 | 131 | <div class="grid grid-cols-2 gap-3"> |
| 132 | 132 | <div class="text-center p-3 bg-orange-50 dark:bg-orange-900/10 rounded-xl"> |
| 133 | <p class="text-xl font-bold text-orange-600" :text="conqueredCount"></p> | |
| 133 | <p class="text-xl font-bold text-orange-600">{{ conqueredCount }}</p> | |
| 134 | 134 | <p class="text-xs text-gray-500 dark:text-slate-400">Conquests</p> |
| 135 | 135 | </div> |
| 136 | 136 | <div class="text-center p-3 bg-emerald-50 dark:bg-emerald-900/10 rounded-xl"> |
| 137 | <p class="text-xl font-bold text-emerald-600" :text="defendedCount"></p> | |
| 137 | <p class="text-xl font-bold text-emerald-600">{{ defendedCount }}</p> | |
| 138 | 138 | <p class="text-xs text-gray-500 dark:text-slate-400">Defenses</p> |
| 139 | 139 | </div> |
| 140 | 140 | </div> |
| @@ -49,34 +49,34 @@ | ||
| 49 | 49 | <template :for="challenge in filtered"> |
| 50 | 50 | <div class="card"> |
| 51 | 51 | <div class="flex items-center justify-between mb-4"> |
| 52 | <span class="px-2 py-0.5 font-semibold text-xs rounded-full" x-class="challenge.status === 'active' ? 'bg-orange-100 text-orange-700' : challenge.status === 'pending' ? 'bg-yellow-100 text-yellow-700' : challenge.status === 'declined' ? 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300' : challenge.winner_id === currentUserId ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'" :text="challenge.status === 'active' ? 'IN PROGRESS' : challenge.status === 'pending' ? 'PENDING' : challenge.status === 'declined' ? 'DECLINED' : challenge.winner_id === currentUserId ? 'WON' : 'LOST'"></span> | |
| 53 | <span class="text-gray-500 text-xs dark:text-slate-400" :text="tb.timeAgo(challenge.created_at)"></span> | |
| 52 | <span class="px-2 py-0.5 font-semibold text-xs rounded-full" x-class="challenge.status === 'active' ? 'bg-orange-100 text-orange-700' : challenge.status === 'pending' ? 'bg-yellow-100 text-yellow-700' : challenge.status === 'declined' ? 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300' : challenge.winner_id === currentUserId ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'">{{ challenge.status === 'active' ? 'IN PROGRESS' : challenge.status === 'pending' ? 'PENDING' : challenge.status === 'declined' ? 'DECLINED' : challenge.winner_id === currentUserId ? 'WON' : 'LOST' }}</span> | |
| 53 | <span class="text-gray-500 text-xs dark:text-slate-400">{{ tb.timeAgo(challenge.created_at) }}</span> | |
| 54 | 54 | </div> |
| 55 | 55 | |
| 56 | 56 | <div class="flex items-center justify-between gap-4"> |
| 57 | 57 | <div class="flex items-center gap-3 flex-1"> |
| 58 | <div class="flex items-center justify-center h-12 w-12 font-bold text-orange-600 bg-orange-100 dark:bg-orange-900/20 rounded-full" :text="challenge.challengerName.charAt(0)"></div> | |
| 58 | <div class="flex items-center justify-center h-12 w-12 font-bold text-orange-600 bg-orange-100 dark:bg-orange-900/20 rounded-full">{{ challenge.challengerName.charAt(0) }}</div> | |
| 59 | 59 | <div> |
| 60 | <p class="font-semibold text-sm text-gray-900 dark:text-white" :text="challenge.challenger_id === currentUserId ? 'You' : challenge.challengerName"></p> | |
| 60 | <p class="font-semibold text-sm text-gray-900 dark:text-white">{{ challenge.challenger_id === currentUserId ? 'You' : challenge.challengerName }}</p> | |
| 61 | 61 | <p class="text-xs text-gray-500 dark:text-slate-400">Challenger</p> |
| 62 | 62 | </div> |
| 63 | 63 | </div> |
| 64 | 64 | <div class="shrink-0 w-10 h-10 rounded-full bg-gray-100 dark:bg-slate-700 flex items-center justify-center text-xs font-bold text-gray-500 dark:text-slate-400">VS</div> |
| 65 | 65 | <div class="flex items-center gap-3 flex-1 justify-end text-right"> |
| 66 | 66 | <div> |
| 67 | <p class="font-semibold text-sm text-gray-900 dark:text-white" :text="challenge.challenged_id === currentUserId ? 'You' : challenge.challengedName"></p> | |
| 67 | <p class="font-semibold text-sm text-gray-900 dark:text-white">{{ challenge.challenged_id === currentUserId ? 'You' : challenge.challengedName }}</p> | |
| 68 | 68 | <p class="text-xs text-gray-500 dark:text-slate-400">Defender</p> |
| 69 | 69 | </div> |
| 70 | <div class="flex items-center justify-center h-12 w-12 font-bold text-blue-600 bg-blue-100 dark:bg-blue-900/20 rounded-full" :text="challenge.challengedName.charAt(0)"></div> | |
| 70 | <div class="flex items-center justify-center h-12 w-12 font-bold text-blue-600 bg-blue-100 dark:bg-blue-900/20 rounded-full">{{ challenge.challengedName.charAt(0) }}</div> | |
| 71 | 71 | </div> |
| 72 | 72 | </div> |
| 73 | 73 | |
| 74 | 74 | <div class="mt-4 p-3 bg-gray-50 dark:bg-slate-700/50 rounded-xl flex items-center justify-between"> |
| 75 | 75 | <div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-300"> |
| 76 | 76 | Territory: |
| 77 | <StxLink x-to="'/territory/' + challenge.territory_id" class="font-medium text-emerald-600 hover:underline" :text="challenge.territoryName"></StxLink> | |
| 77 | <StxLink x-to="'/territory/' + challenge.territory_id" class="font-medium text-emerald-600 hover:underline">{{ challenge.territoryName }}</StxLink> | |
| 78 | 78 | </div> |
| 79 | <span class="text-xs text-gray-500 dark:text-slate-400" :text="(challenge.areaAtStake / 1000).toFixed(1) + ' km²'"></span> | |
| 79 | <span class="text-xs text-gray-500 dark:text-slate-400">{{ (challenge.areaAtStake / 1000).toFixed(1) + ' km²' }}</span> | |
| 80 | 80 | </div> |
| 81 | 81 | |
| 82 | 82 | <!-- Accept / decline (received + pending only) (#965) --> |
| @@ -84,7 +84,7 @@ | ||
| 84 | 84 | <button @click="respond(challenge, 'accept')" x-disabled="busyId === challenge.id" class="flex-1 px-4 py-2 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition disabled:opacity-60">Accept</button> |
| 85 | 85 | <button @click="respond(challenge, 'decline')" x-disabled="busyId === challenge.id" class="flex-1 px-4 py-2 border border-gray-200 dark:border-slate-600 text-gray-600 dark:text-slate-300 text-sm font-semibold rounded-lg hover:border-red-400 hover:text-red-500 transition disabled:opacity-60">Decline</button> |
| 86 | 86 | </div> |
| 87 | <p :if="challenge.challenger_id === currentUserId && challenge.status === 'pending'" class="mt-3 text-xs text-gray-400 text-center">Waiting for <span :text="challenge.challengedName"></span> to respond…</p> | |
| 87 | <p :if="challenge.challenger_id === currentUserId && challenge.status === 'pending'" class="mt-3 text-xs text-gray-400 text-center">Waiting for <span>{{ challenge.challengedName }}</span> to respond…</p> | |
| 88 | 88 | </div> |
| 89 | 89 | </template> |
| 90 | 90 | |
| @@ -112,14 +112,14 @@ | ||
| 112 | 112 | <label class="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Territory to challenge for</label> |
| 113 | 113 | <select @change="targetTerritoryId.set($event.target.value)" class="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm"> |
| 114 | 114 | <template :for="t in rivalTerritories()"> |
| 115 | <option x-value="t.id" x-selected="String(t.id) === targetTerritoryId" x-text="t.name + ' (' + (t.areaSize / 1000).toFixed(1) + ' km²)'"></option> | |
| 115 | <option x-value="t.id" x-selected="String(t.id) === targetTerritoryId" :text="t.name + ' (' + (t.areaSize / 1000).toFixed(1) + ' km²)'"></option> | |
| 116 | 116 | </template> |
| 117 | 117 | </select> |
| 118 | 118 | </div> |
| 119 | 119 | <p class="text-xs text-gray-400">You'll challenge whoever currently holds it. The territory's area is what's at stake.</p> |
| 120 | <p :if="createError" class="text-sm text-red-500" :text="createError"></p> | |
| 120 | <p :if="createError" class="text-sm text-red-500">{{ createError }}</p> | |
| 121 | 121 | <div class="flex gap-2 pt-1"> |
| 122 | <button @click="submitCreate()" class="flex-1 px-4 py-2.5 bg-orange-600 text-white text-sm font-semibold rounded-lg hover:bg-orange-700 transition" :text="submitting ? 'Sending…' : 'Throw down'"></button> | |
| 122 | <button @click="submitCreate()" class="flex-1 px-4 py-2.5 bg-orange-600 text-white text-sm font-semibold rounded-lg hover:bg-orange-700 transition">{{ submitting ? 'Sending…' : 'Throw down' }}</button> | |
| 123 | 123 | <button @click="closeCreate()" class="px-4 py-2.5 text-sm font-medium text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 transition">Cancel</button> |
| 124 | 124 | </div> |
| 125 | 125 | </div> |
| @@ -71,27 +71,27 @@ | ||
| 71 | 71 | <!-- Header --> |
| 72 | 72 | <div class="card mb-6"> |
| 73 | 73 | <div class="flex flex-col sm:flex-row gap-6"> |
| 74 | <div class="w-16 h-16 rounded-2xl flex items-center justify-center text-2xl font-bold shrink-0 bg-emerald-100 dark:bg-emerald-900/20 text-emerald-600" :text="club.name.charAt(0)"></div> | |
| 74 | <div class="w-16 h-16 rounded-2xl flex items-center justify-center text-2xl font-bold shrink-0 bg-emerald-100 dark:bg-emerald-900/20 text-emerald-600">{{ club.name.charAt(0) }}</div> | |
| 75 | 75 | <div class="flex-1 min-w-0"> |
| 76 | 76 | <div class="flex items-start justify-between gap-4"> |
| 77 | 77 | <div> |
| 78 | <h1 class="text-2xl font-bold dark:text-white" :text="club.name"></h1> | |
| 78 | <h1 class="text-2xl font-bold dark:text-white">{{ club.name }}</h1> | |
| 79 | 79 | <div class="flex flex-wrap items-center gap-3 text-xs text-gray-500 dark:text-slate-400 mt-1"> |
| 80 | <span :text="club.location || 'Worldwide'"></span> | |
| 81 | <span :text="club.memberCount + ' members'"></span> | |
| 82 | <span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 font-medium" :text="club.type"></span> | |
| 80 | <span>{{ club.location || 'Worldwide' }}</span> | |
| 81 | <span>{{ club.memberCount + ' members' }}</span> | |
| 82 | <span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 font-medium">{{ club.type }}</span> | |
| 83 | 83 | <span :if="club.isPrivate" class="px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium">Private</span> |
| 84 | 84 | </div> |
| 85 | 85 | </div> |
| 86 | 86 | <div class="flex items-center gap-2 shrink-0"> |
| 87 | <button :if="!isOwner" @click="onToggle()" x-disabled="busy" class="px-4 py-2 text-sm font-semibold rounded-lg transition disabled:opacity-60" x-class="club.isMember ? 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'" :text="club.isMember ? 'Joined' : 'Join'"></button> | |
| 87 | <button :if="!isOwner" @click="onToggle()" x-disabled="busy" class="px-4 py-2 text-sm font-semibold rounded-lg transition disabled:opacity-60" x-class="club.isMember ? 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'">{{ club.isMember ? 'Joined' : 'Join' }}</button> | |
| 88 | 88 | <button :if="isOwner" @click="onDelete()" x-disabled="busy" class="px-4 py-2 text-sm font-semibold rounded-lg border border-gray-200 dark:border-slate-600 text-red-500 hover:border-red-400 transition disabled:opacity-60">Delete club</button> |
| 89 | 89 | </div> |
| 90 | 90 | </div> |
| 91 | <p class="text-sm text-gray-600 dark:text-slate-400 mt-3" :text="club.description"></p> | |
| 91 | <p class="text-sm text-gray-600 dark:text-slate-400 mt-3">{{ club.description }}</p> | |
| 92 | 92 | <div class="flex items-center gap-6 pt-3 mt-3 border-t border-gray-100 dark:border-slate-700 text-sm"> |
| 93 | <div><span class="font-semibold dark:text-white" :text="club.weeklyDistance + ' mi'"></span> <span class="text-gray-500 dark:text-slate-400">this week</span></div> | |
| 94 | <div><span class="font-semibold dark:text-white" :text="club.activitiesThisWeek"></span> <span class="text-gray-500 dark:text-slate-400">activities</span></div> | |
| 93 | <div><span class="font-semibold dark:text-white">{{ club.weeklyDistance + ' mi' }}</span> <span class="text-gray-500 dark:text-slate-400">this week</span></div> | |
| 94 | <div><span class="font-semibold dark:text-white">{{ club.activitiesThisWeek }}</span> <span class="text-gray-500 dark:text-slate-400">activities</span></div> | |
| 95 | 95 | </div> |
| 96 | 96 | </div> |
| 97 | 97 | </div> |
| @@ -103,14 +103,14 @@ | ||
| 103 | 103 | <h2 class="font-bold text-lg dark:text-white">Recent activity</h2> |
| 104 | 104 | <template :for="a in club.recentFeed"> |
| 105 | 105 | <StxLink x-to="'/activity/' + a.id" class="card flex items-center gap-4 hover:shadow-md transition block"> |
| 106 | <div class="w-10 h-10 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 font-bold shrink-0" :text="a.userName.charAt(0)"></div> | |
| 106 | <div class="w-10 h-10 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 font-bold shrink-0">{{ a.userName.charAt(0) }}</div> | |
| 107 | 107 | <div class="flex-1 min-w-0"> |
| 108 | <p class="font-semibold text-sm dark:text-white truncate" :text="a.userName + ' · ' + a.activityType"></p> | |
| 109 | <p class="text-xs text-gray-500 dark:text-slate-400" :text="tb.timeAgo(a.completedAt)"></p> | |
| 108 | <p class="font-semibold text-sm dark:text-white truncate">{{ a.userName + ' · ' + a.activityType }}</p> | |
| 109 | <p class="text-xs text-gray-500 dark:text-slate-400">{{ tb.timeAgo(a.completedAt) }}</p> | |
| 110 | 110 | </div> |
| 111 | 111 | <div class="text-right shrink-0"> |
| 112 | <p class="font-bold text-sm dark:text-white" :text="(a.distance || 0).toFixed(1) + ' mi'"></p> | |
| 113 | <p class="metric-label" :text="a.duration"></p> | |
| 112 | <p class="font-bold text-sm dark:text-white">{{ (a.distance || 0).toFixed(1) + ' mi' }}</p> | |
| 113 | <p class="metric-label">{{ a.duration }}</p> | |
| 114 | 114 | </div> |
| 115 | 115 | </StxLink> |
| 116 | 116 | </template> |
| @@ -129,9 +129,9 @@ | ||
| 129 | 129 | <div class="space-y-2"> |
| 130 | 130 | <template :for="row in club.leaderboard"> |
| 131 | 131 | <div class="flex items-center gap-3 text-sm"> |
| 132 | <span class="w-5 text-center font-bold" x-class="row.rank <= 3 ? 'text-yellow-500' : 'text-gray-400'" :text="row.rank"></span> | |
| 133 | <StxLink x-to="'/athlete/' + row.userId" class="flex-1 truncate dark:text-white hover:text-emerald-600 transition" :text="row.name"></StxLink> | |
| 134 | <span class="text-gray-500 dark:text-slate-400" :text="row.weeklyDistance + ' mi'"></span> | |
| 132 | <span class="w-5 text-center font-bold" x-class="row.rank <= 3 ? 'text-yellow-500' : 'text-gray-400'">{{ row.rank }}</span> | |
| 133 | <StxLink x-to="'/athlete/' + row.userId" class="flex-1 truncate dark:text-white hover:text-emerald-600 transition">{{ row.name }}</StxLink> | |
| 134 | <span class="text-gray-500 dark:text-slate-400">{{ row.weeklyDistance + ' mi' }}</span> | |
| 135 | 135 | </div> |
| 136 | 136 | </template> |
| 137 | 137 | <div :if="club.leaderboard.length === 0" class="text-center py-4 text-sm text-gray-500 dark:text-slate-400">No members yet.</div> |
| @@ -139,12 +139,12 @@ | ||
| 139 | 139 | </div> |
| 140 | 140 | |
| 141 | 141 | <div class="card"> |
| 142 | <h3 class="font-bold dark:text-white mb-3" :text="'Members (' + club.memberCount + ')'"></h3> | |
| 142 | <h3 class="font-bold dark:text-white mb-3">{{ 'Members (' + club.memberCount + ')' }}</h3> | |
| 143 | 143 | <div class="space-y-2"> |
| 144 | 144 | <template :for="m in club.members"> |
| 145 | 145 | <div class="flex items-center gap-3 text-sm"> |
| 146 | <StxLink x-to="'/athlete/' + m.userId" class="flex-1 truncate dark:text-white hover:text-emerald-600 transition" :text="m.name"></StxLink> | |
| 147 | <span :if="m.role !== 'member'" class="px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium capitalize" :text="m.role"></span> | |
| 146 | <StxLink x-to="'/athlete/' + m.userId" class="flex-1 truncate dark:text-white hover:text-emerald-600 transition">{{ m.name }}</StxLink> | |
| 147 | <span :if="m.role !== 'member'" class="px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium capitalize">{{ m.role }}</span> | |
| 148 | 148 | </div> |
| 149 | 149 | </template> |
| 150 | 150 | </div> |
| @@ -47,28 +47,28 @@ | ||
| 47 | 47 | <template :for="club in filtered"> |
| 48 | 48 | <div class="card"> |
| 49 | 49 | <div class="flex flex-col sm:flex-row gap-6"> |
| 50 | <div class="w-16 h-16 rounded-2xl flex items-center justify-center text-2xl font-bold shrink-0" x-class="club.type === 'Running' ? 'bg-emerald-100 dark:bg-emerald-900/20 text-emerald-600' : club.type === 'Hiking' ? 'bg-blue-100 dark:bg-blue-900/20 text-blue-600' : club.type === 'Territory Game' ? 'bg-orange-100 dark:bg-orange-900/20 text-orange-600' : 'bg-purple-100 dark:bg-purple-900/20 text-purple-600'" :text="club.name.charAt(0)"></div> | |
| 50 | <div class="w-16 h-16 rounded-2xl flex items-center justify-center text-2xl font-bold shrink-0" x-class="club.type === 'Running' ? 'bg-emerald-100 dark:bg-emerald-900/20 text-emerald-600' : club.type === 'Hiking' ? 'bg-blue-100 dark:bg-blue-900/20 text-blue-600' : club.type === 'Territory Game' ? 'bg-orange-100 dark:bg-orange-900/20 text-orange-600' : 'bg-purple-100 dark:bg-purple-900/20 text-purple-600'">{{ club.name.charAt(0) }}</div> | |
| 51 | 51 | <div class="flex-1 min-w-0"> |
| 52 | 52 | <div class="flex items-start justify-between gap-4 mb-2"> |
| 53 | 53 | <div> |
| 54 | <StxLink x-to="'/club/' + club.id" class="font-bold text-lg text-gray-900 dark:text-white hover:text-emerald-600 transition" :text="club.name"></StxLink> | |
| 54 | <StxLink x-to="'/club/' + club.id" class="font-bold text-lg text-gray-900 dark:text-white hover:text-emerald-600 transition">{{ club.name }}</StxLink> | |
| 55 | 55 | <div class="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-400 mt-1"> |
| 56 | <span :text="club.location"></span> | |
| 57 | <span :text="club.memberCount + ' members'"></span> | |
| 58 | <span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 font-medium" :text="club.type"></span> | |
| 56 | <span>{{ club.location }}</span> | |
| 57 | <span>{{ club.memberCount + ' members' }}</span> | |
| 58 | <span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 font-medium">{{ club.type }}</span> | |
| 59 | 59 | <span :if="club.isPrivate" class="px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium">Private</span> |
| 60 | 60 | </div> |
| 61 | 61 | </div> |
| 62 | <button @click="onToggleMembership(club)" class="shrink-0 px-4 py-2 text-sm font-semibold rounded-lg transition" x-class="isMember(club) ? 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'" :text="isMember(club) ? 'Joined' : 'Join'"></button> | |
| 62 | <button @click="onToggleMembership(club)" class="shrink-0 px-4 py-2 text-sm font-semibold rounded-lg transition" x-class="isMember(club) ? 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'">{{ isMember(club) ? 'Joined' : 'Join' }}</button> | |
| 63 | 63 | </div> |
| 64 | <p class="text-sm text-gray-600 dark:text-slate-400 mb-4" :text="club.description"></p> | |
| 64 | <p class="text-sm text-gray-600 dark:text-slate-400 mb-4">{{ club.description }}</p> | |
| 65 | 65 | <div class="flex items-center gap-6 pt-3 border-t border-gray-100 dark:border-slate-700 text-sm"> |
| 66 | 66 | <div class="flex items-center gap-1.5"> |
| 67 | <span class="font-semibold dark:text-white" :text="club.weeklyDistance + ' mi'"></span> | |
| 67 | <span class="font-semibold dark:text-white">{{ club.weeklyDistance + ' mi' }}</span> | |
| 68 | 68 | <span class="text-gray-500 dark:text-slate-400">this week</span> |
| 69 | 69 | </div> |
| 70 | 70 | <div class="flex items-center gap-1.5"> |
| 71 | <span class="font-semibold dark:text-white" :text="club.activitiesThisWeek"></span> | |
| 71 | <span class="font-semibold dark:text-white">{{ club.activitiesThisWeek }}</span> | |
| 72 | 72 | <span class="text-gray-500 dark:text-slate-400">activities</span> |
| 73 | 73 | </div> |
| 74 | 74 | </div> |
| @@ -124,9 +124,9 @@ | ||
| 124 | 124 | <input type="checkbox" x-checked="fPrivate" @change="fPrivate.set($event.target.checked)" class="h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500" /> |
| 125 | 125 | Private — only members can see it |
| 126 | 126 | </label> |
| 127 | <p :if="createError" class="text-sm text-red-500" :text="createError"></p> | |
| 127 | <p :if="createError" class="text-sm text-red-500">{{ createError }}</p> | |
| 128 | 128 | <div class="flex gap-2 pt-1"> |
| 129 | <button @click="submitCreate()" class="flex-1 px-4 py-2.5 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition" :text="submitting ? 'Creating…' : 'Create club'"></button> | |
| 129 | <button @click="submitCreate()" class="flex-1 px-4 py-2.5 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition">{{ submitting ? 'Creating…' : 'Create club' }}</button> | |
| 130 | 130 | <button @click="closeCreate()" class="px-4 py-2.5 text-sm font-medium text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 transition">Cancel</button> |
| 131 | 131 | </div> |
| 132 | 132 | </div> |
| @@ -45,19 +45,19 @@ | ||
| 45 | 45 | <!-- Stats --> |
| 46 | 46 | <div :if="conquestStats" class="grid gap-4 grid-cols-2 sm:grid-cols-4 mb-8"> |
| 47 | 47 | <div class="text-center card"> |
| 48 | <p class="font-bold text-2xl text-orange-600" :text="conquestStats.totalConquests"></p> | |
| 48 | <p class="font-bold text-2xl text-orange-600">{{ conquestStats.totalConquests }}</p> | |
| 49 | 49 | <p class="metric-label">Conquered</p> |
| 50 | 50 | </div> |
| 51 | 51 | <div class="text-center card"> |
| 52 | <p class="font-bold text-2xl text-red-600" :text="conquestStats.territoriesLost"></p> | |
| 52 | <p class="font-bold text-2xl text-red-600">{{ conquestStats.territoriesLost }}</p> | |
| 53 | 53 | <p class="metric-label">Lost</p> |
| 54 | 54 | </div> |
| 55 | 55 | <div class="text-center card"> |
| 56 | <p class="font-bold text-2xl text-emerald-600" :text="conquestStats.successfulDefenses"></p> | |
| 56 | <p class="font-bold text-2xl text-emerald-600">{{ conquestStats.successfulDefenses }}</p> | |
| 57 | 57 | <p class="metric-label">Defended</p> |
| 58 | 58 | </div> |
| 59 | 59 | <div class="text-center card"> |
| 60 | <p class="font-bold text-2xl text-blue-600" :text="conquestStats.winRate + '%'"></p> | |
| 60 | <p class="font-bold text-2xl text-blue-600">{{ conquestStats.winRate + '%' }}</p> | |
| 61 | 61 | <p class="metric-label">Win Rate</p> |
| 62 | 62 | </div> |
| 63 | 63 | </div> |
| @@ -81,14 +81,14 @@ | ||
| 81 | 81 | </div> |
| 82 | 82 | <div class="flex-1 min-w-0"> |
| 83 | 83 | <div class="flex gap-2 items-center mb-1"> |
| 84 | <span class="px-2 py-0.5 font-semibold text-xs rounded-full" x-class="conquest.attacker_id === currentUserId ? 'bg-orange-100 text-orange-700' : conquest.status === 'defended' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'" :text="conquest.attacker_id === currentUserId ? 'ATTACK' : conquest.status === 'defended' ? 'DEFENDED' : 'CONQUERED'"></span> | |
| 85 | <span class="text-xs text-gray-500 dark:text-slate-400" :text="tb.timeAgo(conquest.created_at)"></span> | |
| 84 | <span class="px-2 py-0.5 font-semibold text-xs rounded-full" x-class="conquest.attacker_id === currentUserId ? 'bg-orange-100 text-orange-700' : conquest.status === 'defended' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'">{{ conquest.attacker_id === currentUserId ? 'ATTACK' : conquest.status === 'defended' ? 'DEFENDED' : 'CONQUERED' }}</span> | |
| 85 | <span class="text-xs text-gray-500 dark:text-slate-400">{{ tb.timeAgo(conquest.created_at) }}</span> | |
| 86 | 86 | </div> |
| 87 | 87 | <p class="font-semibold text-gray-900 dark:text-white"> |
| 88 | <span :text="conquest.attacker_id === currentUserId ? 'You conquered' : conquest.attackerName + ' attacked'"></span> | |
| 89 | <StxLink x-to="'/territory/' + conquest.territory_id" class="text-emerald-600 hover:underline" :text="' ' + conquest.territoryName"></StxLink> | |
| 88 | <span>{{ conquest.attacker_id === currentUserId ? 'You conquered' : conquest.attackerName + ' attacked' }}</span> | |
| 89 | <StxLink x-to="'/territory/' + conquest.territory_id" class="text-emerald-600 hover:underline">{{ ' ' + conquest.territoryName }}</StxLink> | |
| 90 | 90 | </p> |
| 91 | <p class="text-sm text-gray-500 dark:text-slate-400 mt-1" :text="conquest.description"></p> | |
| 91 | <p class="text-sm text-gray-500 dark:text-slate-400 mt-1">{{ conquest.description }}</p> | |
| 92 | 92 | </div> |
| 93 | 93 | </div> |
| 94 | 94 | </div> |
| @@ -112,15 +112,15 @@ | ||
| 112 | 112 | <div :if="conquestStats" class="space-y-3"> |
| 113 | 113 | <div class="flex items-center justify-between p-3 bg-orange-50 dark:bg-orange-900/10 rounded-xl"> |
| 114 | 114 | <span class="text-sm text-gray-600 dark:text-slate-400">Power Rating</span> |
| 115 | <span class="font-bold text-orange-600" :text="conquestStats.powerRating"></span> | |
| 115 | <span class="font-bold text-orange-600">{{ conquestStats.powerRating }}</span> | |
| 116 | 116 | </div> |
| 117 | 117 | <div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/50 rounded-xl"> |
| 118 | 118 | <span class="text-sm text-gray-600 dark:text-slate-400">Longest Streak</span> |
| 119 | <span class="font-bold dark:text-white" :text="conquestStats.longestConquestStreak + ' wins'"></span> | |
| 119 | <span class="font-bold dark:text-white">{{ conquestStats.longestConquestStreak + ' wins' }}</span> | |
| 120 | 120 | </div> |
| 121 | 121 | <div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/50 rounded-xl"> |
| 122 | 122 | <span class="text-sm text-gray-600 dark:text-slate-400">Rivals Beaten</span> |
| 123 | <span class="font-bold dark:text-white" :text="conquestStats.uniqueRivalsBeaten"></span> | |
| 123 | <span class="font-bold dark:text-white">{{ conquestStats.uniqueRivalsBeaten }}</span> | |
| 124 | 124 | </div> |
| 125 | 125 | </div> |
| 126 | 126 | </div> |
| @@ -133,10 +133,10 @@ | ||
| 133 | 133 | <div class="space-y-3"> |
| 134 | 134 | <template :for="conquest in underAttack"> |
| 135 | 135 | <div class="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/10 rounded-xl"> |
| 136 | <div class="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center text-red-600 text-xs font-bold" :text="conquest.attackerName.charAt(0)"></div> | |
| 136 | <div class="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center text-red-600 text-xs font-bold">{{ conquest.attackerName.charAt(0) }}</div> | |
| 137 | 137 | <div class="flex-1 min-w-0"> |
| 138 | <p class="text-sm font-semibold text-gray-900 dark:text-white truncate" :text="conquest.attackerName"></p> | |
| 139 | <p class="text-xs text-red-600" :text="'Targeting ' + conquest.territoryName"></p> | |
| 138 | <p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{{ conquest.attackerName }}</p> | |
| 139 | <p class="text-xs text-red-600">{{ 'Targeting ' + conquest.territoryName }}</p> | |
| 140 | 140 | </div> |
| 141 | 141 | <StxLink x-to="'/territory/' + conquest.territory_id" class="text-xs px-2 py-1 bg-red-600 text-white rounded-lg hover:bg-red-700 transition font-medium">Defend</StxLink> |
| 142 | 142 | </div> |
| @@ -108,13 +108,13 @@ | ||
| 108 | 108 | <div class="card"> |
| 109 | 109 | <div class="flex items-center justify-between mb-4"> |
| 110 | 110 | <div class="flex gap-3 items-center"> |
| 111 | <div class="flex items-center justify-center h-10 w-10 font-bold text-emerald-600 text-sm bg-emerald-100 dark:bg-emerald-900/30 rounded-full" :text="activity.userName.charAt(0)"></div> | |
| 111 | <div class="flex items-center justify-center h-10 w-10 font-bold text-emerald-600 text-sm bg-emerald-100 dark:bg-emerald-900/30 rounded-full">{{ activity.userName.charAt(0) }}</div> | |
| 112 | 112 | <div> |
| 113 | <StxLink x-to="'/athlete/' + activity.user_id" class="font-semibold text-sm dark:text-white hover:text-emerald-600 transition" :text="activity.userName"></StxLink> | |
| 113 | <StxLink x-to="'/athlete/' + activity.user_id" class="font-semibold text-sm dark:text-white hover:text-emerald-600 transition">{{ activity.userName }}</StxLink> | |
| 114 | 114 | <p class="text-gray-500 text-xs dark:text-slate-400"> |
| 115 | <span :text="activity.activityType"></span> | |
| 115 | <span>{{ activity.activityType }}</span> | |
| 116 | 116 | <span class="mx-1">·</span> |
| 117 | <span :text="tb.timeAgo(activity.created_at)"></span> | |
| 117 | <span>{{ tb.timeAgo(activity.created_at) }}</span> | |
| 118 | 118 | </p> |
| 119 | 119 | </div> |
| 120 | 120 | </div> |
| @@ -123,10 +123,10 @@ | ||
| 123 | 123 | @click="onFollow(activity.user_id)" |
| 124 | 124 | x-class="tb.isFollowing(activity.user_id) ? 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300' : 'bg-emerald-600 text-white hover:bg-emerald-700'" |
| 125 | 125 | class="px-3 py-1.5 font-semibold text-xs rounded-lg transition" |
| 126 | :text="tb.isFollowing(activity.user_id) ? 'Following' : 'Follow'"></button> | |
| 126 | >{{ tb.isFollowing(activity.user_id) ? 'Following' : 'Follow' }}</button> | |
| 127 | 127 | </div> |
| 128 | 128 | |
| 129 | <StxLink x-to="'/activity/' + activity.id" class="font-bold text-gray-900 text-lg dark:text-white hover:text-emerald-600 transition block" :text="activity.title"></StxLink> | |
| 129 | <StxLink x-to="'/activity/' + activity.id" class="font-bold text-gray-900 text-lg dark:text-white hover:text-emerald-600 transition block">{{ activity.title }}</StxLink> | |
| 130 | 130 | <p :if="activity.title.includes('Capture')" class="mt-1 text-xs text-purple-600 font-medium">Territory capture run</p> |
| 131 | 131 | |
| 132 | 132 | <div :if="activityRoutePreview(activity)" class="route-preview mt-3" x-style="'background-image:url(' + activityRoutePreview(activity) + ')'"></div> |
| @@ -139,19 +139,19 @@ | ||
| 139 | 139 | |
| 140 | 140 | <div class="grid gap-3 grid-cols-2 sm:grid-cols-4 mt-4"> |
| 141 | 141 | <div class="text-center"> |
| 142 | <p class="font-bold text-lg dark:text-white" :text="activity.distance.toFixed(1)"></p> | |
| 142 | <p class="font-bold text-lg dark:text-white">{{ activity.distance.toFixed(1) }}</p> | |
| 143 | 143 | <p class="metric-label">Miles</p> |
| 144 | 144 | </div> |
| 145 | 145 | <div class="text-center"> |
| 146 | <p class="font-bold text-lg dark:text-white" :text="activity.duration"></p> | |
| 146 | <p class="font-bold text-lg dark:text-white">{{ activity.duration }}</p> | |
| 147 | 147 | <p class="metric-label">Time</p> |
| 148 | 148 | </div> |
| 149 | 149 | <div class="text-center"> |
| 150 | <p class="font-bold text-lg dark:text-white" :text="activity.pace"></p> | |
| 150 | <p class="font-bold text-lg dark:text-white">{{ activity.pace }}</p> | |
| 151 | 151 | <p class="metric-label">Pace</p> |
| 152 | 152 | </div> |
| 153 | 153 | <div class="text-center"> |
| 154 | <p class="font-bold text-lg dark:text-white" :text="activity.elevation_gain.toLocaleString()"></p> | |
| 154 | <p class="font-bold text-lg dark:text-white">{{ activity.elevation_gain.toLocaleString() }}</p> | |
| 155 | 155 | <p class="metric-label">Elev (ft)</p> |
| 156 | 156 | </div> |
| 157 | 157 | </div> |
| @@ -159,7 +159,7 @@ | ||
| 159 | 159 | <div class="flex gap-6 items-center mt-4 pt-4 border-gray-100 border-t dark:border-slate-700"> |
| 160 | 160 | <button @click="onKudos(activity)" x-class="kudosed[activity.id] ? 'text-red-500' : 'text-gray-500 dark:text-slate-400 hover:text-red-500'" class="flex items-center gap-1 transition text-sm"> |
| 161 | 161 | <Icon name="heart" size="20" /> |
| 162 | <span :text="activity.kudos_count + ' Kudos'"></span> | |
| 162 | <span>{{ activity.kudos_count + ' Kudos' }}</span> | |
| 163 | 163 | </button> |
| 164 | 164 | <StxLink x-to="'/activity/' + activity.id" class="flex items-center gap-1 text-gray-500 dark:text-slate-400 hover:text-emerald-500 transition text-sm"> |
| 165 | 165 | <Icon name="message-circle" size="20" /> |
| @@ -224,7 +224,7 @@ | ||
| 224 | 224 | <select @change="mTrailId.set($event.target.value)" class="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm"> |
| 225 | 225 | <option value="">No trail</option> |
| 226 | 226 | <template :for="trail in tb.trails()"> |
| 227 | <option x-value="trail.id" x-selected="String(trail.id) === mTrailId" x-text="trail.name"></option> | |
| 227 | <option x-value="trail.id" x-selected="String(trail.id) === mTrailId" :text="trail.name"></option> | |
| 228 | 228 | </template> |
| 229 | 229 | </select> |
| 230 | 230 | </div> |
| @@ -232,9 +232,9 @@ | ||
| 232 | 232 | <label class="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Notes</label> |
| 233 | 233 | <textarea rows="2" placeholder="How did it go?" x-value="mNotes" @input="mNotes.set($event.target.value)" class="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm"></textarea> |
| 234 | 234 | </div> |
| 235 | <p :if="manualError" class="text-sm text-red-500" :text="manualError"></p> | |
| 235 | <p :if="manualError" class="text-sm text-red-500">{{ manualError }}</p> | |
| 236 | 236 | <div class="flex gap-2 pt-1"> |
| 237 | <button @click="submitManualEntry()" class="flex-1 px-4 py-2.5 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition" :text="submitting ? 'Saving…' : 'Log activity'"></button> | |
| 237 | <button @click="submitManualEntry()" class="flex-1 px-4 py-2.5 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition">{{ submitting ? 'Saving…' : 'Log activity' }}</button> | |
| 238 | 238 | <button @click="closeManualEntry()" class="px-4 py-2.5 text-sm font-medium text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 transition">Cancel</button> |
| 239 | 239 | </div> |
| 240 | 240 | <p class="text-xs text-gray-400">Manual entries don't capture territory — record with GPS to claim ground.</p> |
| @@ -58,13 +58,13 @@ | ||
| 58 | 58 | <Icon name="mail" size="32" class="text-emerald-500" /> |
| 59 | 59 | </div> |
| 60 | 60 | <h2 class="font-semibold text-lg text-gray-900 dark:text-white mb-1">Check your inbox</h2> |
| 61 | <p class="text-sm text-gray-500 dark:text-slate-400 mb-6">If an account exists for <span class="font-medium text-gray-700 dark:text-slate-300" :text="email"></span>, a password reset link is on its way.</p> | |
| 61 | <p class="text-sm text-gray-500 dark:text-slate-400 mb-6">If an account exists for <span class="font-medium text-gray-700 dark:text-slate-300">{{ email }}</span>, a password reset link is on its way.</p> | |
| 62 | 62 | <StxLink to="/login" class="inline-block px-5 py-2.5 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition">Back to sign in</StxLink> |
| 63 | 63 | </div> |
| 64 | 64 | |
| 65 | 65 | <!-- Request form --> |
| 66 | 66 | <div :if="!sent"> |
| 67 | <div :if="error" class="mb-6 p-4 text-red-700 text-sm dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800" :text="error"></div> | |
| 67 | <div :if="error" class="mb-6 p-4 text-red-700 text-sm dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800">{{ error }}</div> | |
| 68 | 68 | |
| 69 | 69 | <form @submit="handleSubmit" class="space-y-5"> |
| 70 | 70 | <div> |
| @@ -41,26 +41,26 @@ | ||
| 41 | 41 | <p class="mb-4 text-xs font-bold text-emerald-400 uppercase tracking-wider">Your empire</p> |
| 42 | 42 | <div class="grid grid-cols-2 gap-4 mb-4"> |
| 43 | 43 | <div> |
| 44 | <p class="text-3xl font-bold text-white" :text="myTerritories().length"></p> | |
| 44 | <p class="text-3xl font-bold text-white">{{ myTerritories().length }}</p> | |
| 45 | 45 | <p class="metric-label text-slate-400">Zones owned</p> |
| 46 | 46 | </div> |
| 47 | 47 | <div> |
| 48 | <p class="text-3xl font-bold text-white" :text="(totalArea() / 1000).toFixed(1) + ' km²'"></p> | |
| 48 | <p class="text-3xl font-bold text-white">{{ (totalArea() / 1000).toFixed(1) + ' km²' }}</p> | |
| 49 | 49 | <p class="metric-label text-slate-400">Turf controlled</p> |
| 50 | 50 | </div> |
| 51 | 51 | <div> |
| 52 | <p class="text-3xl font-bold text-emerald-400" :text="gameStats?.powerRating ?? 0"></p> | |
| 52 | <p class="text-3xl font-bold text-emerald-400">{{ gameStats?.powerRating ?? 0 }}</p> | |
| 53 | 53 | <p class="metric-label text-slate-400">Power rating</p> |
| 54 | 54 | </div> |
| 55 | 55 | <div> |
| 56 | <p class="text-3xl font-bold text-white" :text="stats?.weeklyDistance?.toFixed(1) ?? '0'"></p> | |
| 56 | <p class="text-3xl font-bold text-white">{{ stats?.weeklyDistance?.toFixed(1) ?? '0' }}</p> | |
| 57 | 57 | <p class="metric-label text-slate-400">Mi this week</p> |
| 58 | 58 | </div> |
| 59 | 59 | </div> |
| 60 | 60 | <div :if="liveBattles.length > 0" class="pt-4 border-t border-white/10"> |
| 61 | 61 | <p class="flex gap-2 items-center mb-2 text-xs font-semibold text-orange-400"> |
| 62 | 62 | <span class="h-2 w-2 bg-red-500 rounded-full animate-pulse"></span> |
| 63 | <span :text="liveBattles.length + ' live battle' + (liveBattles.length > 1 ? 's' : '')"></span> | |
| 63 | <span>{{ liveBattles.length + ' live battle' + (liveBattles.length > 1 ? 's' : '') }}</span> | |
| 64 | 64 | </p> |
| 65 | 65 | <StxLink to="/battles" class="text-sm text-white/80 hover:text-white underline">Watch the map →</StxLink> |
| 66 | 66 | </div> |
| @@ -75,19 +75,19 @@ | ||
| 75 | 75 | <div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between"> |
| 76 | 76 | <div> |
| 77 | 77 | <h2 class="font-bold text-lg dark:text-white">This week</h2> |
| 78 | <p class="text-sm text-gray-500 dark:text-slate-400" :text="(stats?.weeklyActivities ?? 0) + ' activities · ' + (stats?.currentStreak ?? 0) + ' day streak'"></p> | |
| 78 | <p class="text-sm text-gray-500 dark:text-slate-400">{{ (stats?.weeklyActivities ?? 0) + ' activities · ' + (stats?.currentStreak ?? 0) + ' day streak' }}</p> | |
| 79 | 79 | </div> |
| 80 | 80 | <div class="grid grid-cols-3 gap-6 sm:gap-10 text-center"> |
| 81 | 81 | <div> |
| 82 | <p class="text-2xl font-bold text-emerald-600" :text="stats?.weeklyDistance?.toFixed(1) ?? '0'"></p> | |
| 82 | <p class="text-2xl font-bold text-emerald-600">{{ stats?.weeklyDistance?.toFixed(1) ?? '0' }}</p> | |
| 83 | 83 | <p class="metric-label">Miles</p> |
| 84 | 84 | </div> |
| 85 | 85 | <div> |
| 86 | <p class="text-2xl font-bold text-emerald-600" :text="(stats?.weeklyElevation ?? 0).toLocaleString()"></p> | |
| 86 | <p class="text-2xl font-bold text-emerald-600">{{ (stats?.weeklyElevation ?? 0).toLocaleString() }}</p> | |
| 87 | 87 | <p class="metric-label">Elevation</p> |
| 88 | 88 | </div> |
| 89 | 89 | <div> |
| 90 | <p class="text-2xl font-bold text-emerald-600" :text="gameStats?.totalConquests ?? 0"></p> | |
| 90 | <p class="text-2xl font-bold text-emerald-600">{{ gameStats?.totalConquests ?? 0 }}</p> | |
| 91 | 91 | <p class="metric-label">Conquests</p> |
| 92 | 92 | </div> |
| 93 | 93 | </div> |
| @@ -134,17 +134,17 @@ | ||
| 134 | 134 | <template :for="activity in recent"> |
| 135 | 135 | <StxLink x-to="'/activity/' + activity.id" class="card flex flex-col gap-4 sm:flex-row sm:items-center hover:shadow-md transition block"> |
| 136 | 136 | <div class="flex gap-3 items-center flex-1 min-w-0"> |
| 137 | <div class="shrink-0 flex items-center justify-center h-12 w-12 font-bold text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 rounded-full" :text="activity.userName.charAt(0)"></div> | |
| 137 | <div class="shrink-0 flex items-center justify-center h-12 w-12 font-bold text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">{{ activity.userName.charAt(0) }}</div> | |
| 138 | 138 | <div class="min-w-0"> |
| 139 | <p class="font-bold dark:text-white truncate" :text="activity.title"></p> | |
| 140 | <p class="text-sm text-gray-500 dark:text-slate-400" :text="activity.activityType + ' · ' + tb.timeAgo(activity.created_at)"></p> | |
| 139 | <p class="font-bold dark:text-white truncate">{{ activity.title }}</p> | |
| 140 | <p class="text-sm text-gray-500 dark:text-slate-400">{{ activity.activityType + ' · ' + tb.timeAgo(activity.created_at) }}</p> | |
| 141 | 141 | </div> |
| 142 | 142 | </div> |
| 143 | 143 | <div class="grid grid-cols-4 gap-4 text-center shrink-0"> |
| 144 | <div><p class="font-bold text-emerald-600" :text="activity.distance.toFixed(1)"></p><p class="metric-label">mi</p></div> | |
| 145 | <div><p class="font-bold dark:text-white text-sm" :text="activity.duration"></p><p class="metric-label">time</p></div> | |
| 146 | <div><p class="font-bold dark:text-white text-sm" :text="activity.pace"></p><p class="metric-label">pace</p></div> | |
| 147 | <div><p class="font-bold dark:text-white text-sm" :text="activity.kudos_count"></p><p class="metric-label">kudos</p></div> | |
| 144 | <div><p class="font-bold text-emerald-600">{{ activity.distance.toFixed(1) }}</p><p class="metric-label">mi</p></div> | |
| 145 | <div><p class="font-bold dark:text-white text-sm">{{ activity.duration }}</p><p class="metric-label">time</p></div> | |
| 146 | <div><p class="font-bold dark:text-white text-sm">{{ activity.pace }}</p><p class="metric-label">pace</p></div> | |
| 147 | <div><p class="font-bold dark:text-white text-sm">{{ activity.kudos_count }}</p><p class="metric-label">kudos</p></div> | |
| 148 | 148 | </div> |
| 149 | 149 | </StxLink> |
| 150 | 150 | </template> |
| @@ -171,10 +171,10 @@ | ||
| 171 | 171 | <template :for="t in myTerritories"> |
| 172 | 172 | <StxLink x-to="'/territory/' + t.id" class="card block hover:border-emerald-400 transition"> |
| 173 | 173 | <div class="flex justify-between items-start mb-2"> |
| 174 | <h3 class="font-bold dark:text-white" :text="t.name"></h3> | |
| 175 | <span class="px-2 py-0.5 text-xs font-semibold text-emerald-700 bg-emerald-100 dark:bg-emerald-900/40 rounded-full" :text="t.status"></span> | |
| 174 | <h3 class="font-bold dark:text-white">{{ t.name }}</h3> | |
| 175 | <span class="px-2 py-0.5 text-xs font-semibold text-emerald-700 bg-emerald-100 dark:bg-emerald-900/40 rounded-full">{{ t.status }}</span> | |
| 176 | 176 | </div> |
| 177 | <p class="text-sm text-gray-500 dark:text-slate-400" :text="(t.areaSize / 1000).toFixed(1) + ' km² · ' + t.defendCount + ' defenses'"></p> | |
| 177 | <p class="text-sm text-gray-500 dark:text-slate-400">{{ (t.areaSize / 1000).toFixed(1) + ' km² · ' + t.defendCount + ' defenses' }}</p> | |
| 178 | 178 | </StxLink> |
| 179 | 179 | </template> |
| 180 | 180 | </div> |
| @@ -73,23 +73,23 @@ | ||
| 73 | 73 | <!-- Top 3 podium --> |
| 74 | 74 | <div :if="leaders.length >= 3" class="grid gap-4 grid-cols-3 mb-8"> |
| 75 | 75 | <div class="pt-8 text-center card"> |
| 76 | <div class="flex items-center justify-center mb-2 mx-auto h-14 w-14 font-bold text-gray-600 text-xl dark:text-slate-300 bg-gray-200 dark:bg-slate-700 rounded-full" :text="(leaders[1]?.userName || 'U').charAt(0)"></div> | |
| 77 | <p class="font-semibold text-sm truncate dark:text-white" :text="leaders[1]?.userName || 'Athlete'"></p> | |
| 76 | <div class="flex items-center justify-center mb-2 mx-auto h-14 w-14 font-bold text-gray-600 text-xl dark:text-slate-300 bg-gray-200 dark:bg-slate-700 rounded-full">{{ (leaders[1]?.userName || 'U').charAt(0) }}</div> | |
| 77 | <p class="font-semibold text-sm truncate dark:text-white">{{ leaders[1]?.userName || 'Athlete' }}</p> | |
| 78 | 78 | <p class="mt-1 font-bold text-gray-400 text-lg">2nd</p> |
| 79 | <p class="font-semibold text-emerald-600 text-sm" :text="metricText(leaders[1])"></p> | |
| 79 | <p class="font-semibold text-emerald-600 text-sm">{{ metricText(leaders[1]) }}</p> | |
| 80 | 80 | </div> |
| 81 | 81 | <div class="ring-2 ring-yellow-400 pt-4 text-center card"> |
| 82 | 82 | <div class="mb-1 text-2xl">��</div> |
| 83 | <div class="flex items-center justify-center mb-2 mx-auto h-16 w-16 font-bold text-2xl text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30 rounded-full" :text="(leaders[0]?.userName || 'U').charAt(0)"></div> | |
| 84 | <p class="font-bold text-sm truncate dark:text-white" :text="leaders[0]?.userName || 'Athlete'"></p> | |
| 83 | <div class="flex items-center justify-center mb-2 mx-auto h-16 w-16 font-bold text-2xl text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30 rounded-full">{{ (leaders[0]?.userName || 'U').charAt(0) }}</div> | |
| 84 | <p class="font-bold text-sm truncate dark:text-white">{{ leaders[0]?.userName || 'Athlete' }}</p> | |
| 85 | 85 | <p class="mt-1 font-bold text-lg text-yellow-500">1st</p> |
| 86 | <p class="font-semibold text-emerald-600 text-sm" :text="metricText(leaders[0])"></p> | |
| 86 | <p class="font-semibold text-emerald-600 text-sm">{{ metricText(leaders[0]) }}</p> | |
| 87 | 87 | </div> |
| 88 | 88 | <div class="pt-8 text-center card"> |
| 89 | <div class="flex items-center justify-center mb-2 mx-auto h-14 w-14 font-bold text-orange-600 text-xl bg-orange-100 dark:bg-orange-900/30 rounded-full" :text="(leaders[2]?.userName || 'U').charAt(0)"></div> | |
| 90 | <p class="font-semibold text-sm truncate dark:text-white" :text="leaders[2]?.userName || 'Athlete'"></p> | |
| 89 | <div class="flex items-center justify-center mb-2 mx-auto h-14 w-14 font-bold text-orange-600 text-xl bg-orange-100 dark:bg-orange-900/30 rounded-full">{{ (leaders[2]?.userName || 'U').charAt(0) }}</div> | |
| 90 | <p class="font-semibold text-sm truncate dark:text-white">{{ leaders[2]?.userName || 'Athlete' }}</p> | |
| 91 | 91 | <p class="mt-1 font-bold text-lg text-orange-400">3rd</p> |
| 92 | <p class="font-semibold text-emerald-600 text-sm" :text="metricText(leaders[2])"></p> | |
| 92 | <p class="font-semibold text-emerald-600 text-sm">{{ metricText(leaders[2]) }}</p> | |
| 93 | 93 | </div> |
| 94 | 94 | </div> |
| 95 | 95 | |
| @@ -97,15 +97,15 @@ | ||
| 97 | 97 | <div class="space-y-2"> |
| 98 | 98 | <template :for="(leader, index) in leaders"> |
| 99 | 99 | <div class="flex gap-4 items-center py-3 card"> |
| 100 | <span class="w-8 font-bold text-center text-sm" x-class="index < 3 ? 'text-emerald-600' : 'text-gray-400 dark:text-slate-500'" :text="index + 1"></span> | |
| 101 | <div class="flex items-center justify-center shrink-0 h-10 w-10 font-bold text-gray-600 text-sm dark:text-slate-300 bg-gray-100 dark:bg-slate-700 rounded-full" :text="leader.userName.charAt(0)"></div> | |
| 100 | <span class="w-8 font-bold text-center text-sm" x-class="index < 3 ? 'text-emerald-600' : 'text-gray-400 dark:text-slate-500'">{{ index + 1 }}</span> | |
| 101 | <div class="flex items-center justify-center shrink-0 h-10 w-10 font-bold text-gray-600 text-sm dark:text-slate-300 bg-gray-100 dark:bg-slate-700 rounded-full">{{ leader.userName.charAt(0) }}</div> | |
| 102 | 102 | <div class="flex-1 min-w-0"> |
| 103 | <StxLink x-to="'/athlete/' + leader.userId" class="font-semibold text-sm truncate dark:text-white hover:text-emerald-600 transition block" :text="leader.userName"></StxLink> | |
| 104 | <p class="text-gray-500 text-xs dark:text-slate-400" :text="leader.trailsCompleted + ' trails completed'"></p> | |
| 103 | <StxLink x-to="'/athlete/' + leader.userId" class="font-semibold text-sm truncate dark:text-white hover:text-emerald-600 transition block">{{ leader.userName }}</StxLink> | |
| 104 | <p class="text-gray-500 text-xs dark:text-slate-400">{{ leader.trailsCompleted + ' trails completed' }}</p> | |
| 105 | 105 | </div> |
| 106 | 106 | <div class="shrink-0 text-right"> |
| 107 | <p class="font-bold text-emerald-600 text-sm" :text="metricText(leader)"></p> | |
| 108 | <p class="text-gray-400 text-xs dark:text-slate-500" :text="leader.totalElevation.toLocaleString() + ' ft'"></p> | |
| 107 | <p class="font-bold text-emerald-600 text-sm">{{ metricText(leader) }}</p> | |
| 108 | <p class="text-gray-400 text-xs dark:text-slate-500">{{ leader.totalElevation.toLocaleString() + ' ft' }}</p> | |
| 109 | 109 | </div> |
| 110 | 110 | </div> |
| 111 | 111 | </template> |
| @@ -50,10 +50,10 @@ | ||
| 50 | 50 | </div> |
| 51 | 51 | |
| 52 | 52 | <!-- Error Alert --> |
| 53 | <div :if="error" class="mb-6 p-4 text-red-700 text-sm dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800" :text="error"></div> | |
| 53 | <div :if="error" class="mb-6 p-4 text-red-700 text-sm dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800">{{ error }}</div> | |
| 54 | 54 | |
| 55 | 55 | <!-- Success Alert --> |
| 56 | <div :if="success" class="mb-6 p-4 text-emerald-700 text-sm dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 rounded-xl dark:border-emerald-800" :text="success"></div> | |
| 56 | <div :if="success" class="mb-6 p-4 text-emerald-700 text-sm dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 rounded-xl dark:border-emerald-800">{{ success }}</div> | |
| 57 | 57 | |
| 58 | 58 | <!-- Login Form --> |
| 59 | 59 | <form @submit="handleLogin" class="space-y-5"> |
| @@ -77,7 +77,7 @@ | ||
| 77 | 77 | <div class="flex items-center justify-between mb-6"> |
| 78 | 78 | <div class="flex items-center gap-3"> |
| 79 | 79 | <h1 class="text-2xl font-bold dark:text-white">Notifications</h1> |
| 80 | <span :if="unreadCount > 0" class="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white" x-text="unreadCount"></span> | |
| 80 | <span :if="unreadCount > 0" class="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">{{ unreadCount }}</span> | |
| 81 | 81 | </div> |
| 82 | 82 | <button :if="unreadCount > 0" @click="markAll()" class="text-sm text-emerald-600 hover:text-emerald-700 font-medium transition flex items-center gap-1.5"> |
| 83 | 83 | <Icon name="check-check" size="16" /> |
| @@ -89,23 +89,23 @@ | ||
| 89 | 89 | <div class="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0"> |
| 90 | 90 | <button @click="filter.set('all')" x-class="filter === 'all' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700'" class="px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition flex items-center gap-1.5"> |
| 91 | 91 | All |
| 92 | <span x-class="filter === 'all' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center" x-text="filterCount('all')"></span> | |
| 92 | <span x-class="filter === 'all' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center">{{ filterCount('all') }}</span> | |
| 93 | 93 | </button> |
| 94 | 94 | <button @click="filter.set('unread')" x-class="filter === 'unread' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700'" class="px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition flex items-center gap-1.5"> |
| 95 | 95 | Unread |
| 96 | <span x-class="filter === 'unread' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center" x-text="filterCount('unread')"></span> | |
| 96 | <span x-class="filter === 'unread' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center">{{ filterCount('unread') }}</span> | |
| 97 | 97 | </button> |
| 98 | 98 | <button @click="filter.set('conquest')" x-class="filter === 'conquest' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700'" class="px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition flex items-center gap-1.5"> |
| 99 | 99 | Conquests |
| 100 | <span x-class="filter === 'conquest' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center" x-text="filterCount('conquest')"></span> | |
| 100 | <span x-class="filter === 'conquest' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center">{{ filterCount('conquest') }}</span> | |
| 101 | 101 | </button> |
| 102 | 102 | <button @click="filter.set('kudos')" x-class="filter === 'kudos' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700'" class="px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition flex items-center gap-1.5"> |
| 103 | 103 | Kudos |
| 104 | <span x-class="filter === 'kudos' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center" x-text="filterCount('kudos')"></span> | |
| 104 | <span x-class="filter === 'kudos' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center">{{ filterCount('kudos') }}</span> | |
| 105 | 105 | </button> |
| 106 | 106 | <button @click="filter.set('challenge')" x-class="filter === 'challenge' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700'" class="px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition flex items-center gap-1.5"> |
| 107 | 107 | Challenges |
| 108 | <span x-class="filter === 'challenge' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center" x-text="filterCount('challenge')"></span> | |
| 108 | <span x-class="filter === 'challenge' ? 'bg-white/20' : 'bg-gray-200 dark:bg-slate-700'" class="px-1.5 py-0.5 text-xs rounded-full min-w-[1.5rem] text-center">{{ filterCount('challenge') }}</span> | |
| 109 | 109 | </button> |
| 110 | 110 | </div> |
| 111 | 111 | |
| @@ -113,7 +113,7 @@ | ||
| 113 | 113 | <div :if="filtered.length > 0" class="space-y-8"> |
| 114 | 114 | <template :for="group in groupedByDay"> |
| 115 | 115 | <div> |
| 116 | <h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-500 mb-3 px-1" x-text="group.label"></h2> | |
| 116 | <h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-500 mb-3 px-1">{{ group.label }}</h2> | |
| 117 | 117 | <div class="space-y-2"> |
| 118 | 118 | <template :for="notif in group.items"> |
| 119 | 119 | <StxLink x-to="notif.link || '#'" @click="markOne(notif.id)" class="group relative flex items-start gap-4 p-4 rounded-xl border transition-all block" x-class="!notif.read ? 'bg-emerald-50/50 dark:bg-emerald-900/10 border-emerald-200 dark:border-emerald-800/40 hover:border-emerald-300 dark:hover:border-emerald-700/60' : 'bg-white dark:bg-slate-800/50 border-gray-200 dark:border-slate-700/50 hover:bg-gray-50 dark:hover:bg-slate-800 hover:border-gray-300 dark:hover:border-slate-600'"> |
| @@ -135,10 +135,10 @@ | ||
| 135 | 135 | <!-- Body --> |
| 136 | 136 | <div class="flex-1 min-w-0"> |
| 137 | 137 | <div class="flex items-start justify-between gap-3"> |
| 138 | <p class="text-sm font-semibold text-gray-900 dark:text-white leading-tight" x-text="notif.title"></p> | |
| 139 | <p class="text-xs text-gray-400 dark:text-slate-500 whitespace-nowrap shrink-0 mt-0.5" x-text="tb.timeAgo(notif.created_at)"></p> | |
| 138 | <p class="text-sm font-semibold text-gray-900 dark:text-white leading-tight">{{ notif.title }}</p> | |
| 139 | <p class="text-xs text-gray-400 dark:text-slate-500 whitespace-nowrap shrink-0 mt-0.5">{{ tb.timeAgo(notif.created_at) }}</p> | |
| 140 | 140 | </div> |
| 141 | <p class="text-sm text-gray-600 dark:text-slate-400 mt-1" x-text="notif.message"></p> | |
| 141 | <p class="text-sm text-gray-600 dark:text-slate-400 mt-1">{{ notif.message }}</p> | |
| 142 | 142 | </div> |
| 143 | 143 | |
| 144 | 144 | <!-- Chevron on hover --> |
| @@ -157,8 +157,8 @@ | ||
| 157 | 157 | <div class="w-20 h-20 mx-auto mb-6 bg-emerald-100 dark:bg-emerald-900/20 rounded-2xl flex items-center justify-center"> |
| 158 | 158 | <Icon name="check-circle" size="40" class="text-emerald-500" /> |
| 159 | 159 | </div> |
| 160 | <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2" x-text="filter === 'unread' ? 'All caught up!' : filter === 'all' ? 'No notifications yet' : 'Nothing here'"></h3> | |
| 161 | <p class="text-gray-500 dark:text-slate-400 max-w-sm mx-auto" x-text="filter === 'unread' ? 'You\'ve read everything. Nice work.' : filter === 'all' ? 'When people give kudos, challenge you, or conquer your territory, it\'ll show up here.' : 'Try a different filter above.'"></p> | |
| 160 | <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ filter === 'unread' ? 'All caught up!' : filter === 'all' ? 'No notifications yet' : 'Nothing here' }}</h3> | |
| 161 | <p class="text-gray-500 dark:text-slate-400 max-w-sm mx-auto">{{ filter === 'unread' ? 'You\'ve read everything. Nice work.' : filter === 'all' ? 'When people give kudos, challenge you, or conquer your territory, it\'ll show up here.' : 'Try a different filter above.' }}</p> | |
| 162 | 162 | </div> |
| 163 | 163 | </div> |
| 164 | 164 | @endsection |
| @@ -44,33 +44,33 @@ | ||
| 44 | 44 | <!-- Profile header --> |
| 45 | 45 | <div class="mb-6 card"> |
| 46 | 46 | <div class="flex flex-col gap-6 items-center sm:flex-row"> |
| 47 | <div class="flex items-center justify-center h-24 w-24 font-bold text-3xl text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 rounded-full" :text="(currentUser?.name || 'U').charAt(0)"></div> | |
| 47 | <div class="flex items-center justify-center h-24 w-24 font-bold text-3xl text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">{{ (currentUser?.name || 'U').charAt(0) }}</div> | |
| 48 | 48 | <div class="flex-1 text-center sm:text-left"> |
| 49 | <h1 class="font-bold text-2xl dark:text-white" :text="currentUser?.name || 'Athlete'"></h1> | |
| 50 | <p class="mt-1 text-gray-500 text-sm dark:text-slate-400" :text="currentUser?.email || ''"></p> | |
| 49 | <h1 class="font-bold text-2xl dark:text-white">{{ currentUser?.name || 'Athlete' }}</h1> | |
| 50 | <p class="mt-1 text-gray-500 text-sm dark:text-slate-400">{{ currentUser?.email || '' }}</p> | |
| 51 | 51 | <div class="flex flex-wrap gap-4 justify-center sm:justify-start mt-3"> |
| 52 | 52 | <div class="text-center"> |
| 53 | <p class="text-lg font-bold text-emerald-600" :text="userStats?.totalDistance.toFixed(1) || '0'"></p> | |
| 53 | <p class="text-lg font-bold text-emerald-600">{{ userStats?.totalDistance.toFixed(1) || '0' }}</p> | |
| 54 | 54 | <p class="metric-label">Miles</p> |
| 55 | 55 | </div> |
| 56 | 56 | <div class="text-center"> |
| 57 | <p class="text-lg font-bold text-emerald-600" :text="userStats?.trailsCompleted || 0"></p> | |
| 57 | <p class="text-lg font-bold text-emerald-600">{{ userStats?.trailsCompleted || 0 }}</p> | |
| 58 | 58 | <p class="metric-label">Trails</p> |
| 59 | 59 | </div> |
| 60 | 60 | <div class="text-center"> |
| 61 | <p class="text-lg font-bold text-emerald-600" :text="(userStats?.totalElevation || 0).toLocaleString()"></p> | |
| 61 | <p class="text-lg font-bold text-emerald-600">{{ (userStats?.totalElevation || 0).toLocaleString() }}</p> | |
| 62 | 62 | <p class="metric-label">Elev (ft)</p> |
| 63 | 63 | </div> |
| 64 | 64 | <div class="text-center"> |
| 65 | <p class="text-lg font-bold text-emerald-600" :text="userStats?.currentStreak || 0"></p> | |
| 65 | <p class="text-lg font-bold text-emerald-600">{{ userStats?.currentStreak || 0 }}</p> | |
| 66 | 66 | <p class="metric-label">Streak</p> |
| 67 | 67 | </div> |
| 68 | 68 | <div class="text-center"> |
| 69 | <p class="text-lg font-bold text-emerald-600" :text="followerCount"></p> | |
| 69 | <p class="text-lg font-bold text-emerald-600">{{ followerCount }}</p> | |
| 70 | 70 | <p class="metric-label">Followers</p> |
| 71 | 71 | </div> |
| 72 | 72 | <div class="text-center"> |
| 73 | <p class="text-lg font-bold text-emerald-600" :text="followingCount"></p> | |
| 73 | <p class="text-lg font-bold text-emerald-600">{{ followingCount }}</p> | |
| 74 | 74 | <p class="metric-label">Following</p> |
| 75 | 75 | </div> |
| 76 | 76 | </div> |
| @@ -93,16 +93,16 @@ | ||
| 93 | 93 | <Icon name="zap" size="24" class="text-emerald-600" /> |
| 94 | 94 | </div> |
| 95 | 95 | <div class="flex-1 min-w-0"> |
| 96 | <p class="font-semibold text-sm truncate dark:text-white" :text="activity.title"></p> | |
| 97 | <p class="text-gray-500 text-xs dark:text-slate-400" :text="activity.activityType + ' · ' + tb.timeAgo(activity.created_at)"></p> | |
| 96 | <p class="font-semibold text-sm truncate dark:text-white">{{ activity.title }}</p> | |
| 97 | <p class="text-gray-500 text-xs dark:text-slate-400">{{ activity.activityType + ' · ' + tb.timeAgo(activity.created_at) }}</p> | |
| 98 | 98 | </div> |
| 99 | 99 | <div class="flex gap-4 shrink-0 text-right"> |
| 100 | 100 | <div> |
| 101 | <p class="font-bold text-sm dark:text-white" :text="activity.distance.toFixed(1) + ' mi'"></p> | |
| 101 | <p class="font-bold text-sm dark:text-white">{{ activity.distance.toFixed(1) + ' mi' }}</p> | |
| 102 | 102 | <p class="metric-label">Distance</p> |
| 103 | 103 | </div> |
| 104 | 104 | <div> |
| 105 | <p class="font-bold text-sm dark:text-white" :text="activity.duration"></p> | |
| 105 | <p class="font-bold text-sm dark:text-white">{{ activity.duration }}</p> | |
| 106 | 106 | <p class="metric-label">Time</p> |
| 107 | 107 | </div> |
| 108 | 108 | </div> |
| @@ -123,15 +123,15 @@ | ||
| 123 | 123 | <div :if="activeTab === 'achievements' && achievements.length > 0" class="grid gap-4 grid-cols-1 sm:grid-cols-2"> |
| 124 | 124 | <template :for="achievement in achievements"> |
| 125 | 125 | <div class="flex gap-4 items-center card"> |
| 126 | <span class="text-3xl" :text="achievement.icon"></span> | |
| 126 | <span class="text-3xl">{{ achievement.icon }}</span> | |
| 127 | 127 | <div class="flex-1"> |
| 128 | <p class="font-semibold text-sm dark:text-white" :text="achievement.name"></p> | |
| 129 | <p class="text-gray-500 text-xs dark:text-slate-400" :text="achievement.description"></p> | |
| 128 | <p class="font-semibold text-sm dark:text-white">{{ achievement.name }}</p> | |
| 129 | <p class="text-gray-500 text-xs dark:text-slate-400">{{ achievement.description }}</p> | |
| 130 | 130 | <div class="overflow-hidden mt-2 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full"> |
| 131 | 131 | <div class="h-full bg-emerald-500 rounded-full" x-style="'width: ' + (achievement.progress / achievement.total * 100) + '%'"></div> |
| 132 | 132 | </div> |
| 133 | 133 | </div> |
| 134 | <p class="shrink-0 text-gray-400 text-xs dark:text-slate-500" :text="achievement.progress + '/' + achievement.total"></p> | |
| 134 | <p class="shrink-0 text-gray-400 text-xs dark:text-slate-500">{{ achievement.progress + '/' + achievement.total }}</p> | |
| 135 | 135 | </div> |
| 136 | 136 | </template> |
| 137 | 137 | </div> |
| @@ -150,14 +150,14 @@ | ||
| 150 | 150 | <template :for="trail in savedTrails"> |
| 151 | 151 | <div class="card flex items-center gap-4 hover:shadow-lg transition"> |
| 152 | 152 | <StxLink x-to="'/trail/' + trail.id" class="flex-1 min-w-0 group"> |
| 153 | <h3 class="font-bold text-gray-900 dark:text-white group-hover:text-emerald-600 transition truncate" :text="trail.name"></h3> | |
| 154 | <p class="text-sm text-gray-500 dark:text-slate-400 truncate" :text="trail.location"></p> | |
| 153 | <h3 class="font-bold text-gray-900 dark:text-white group-hover:text-emerald-600 transition truncate">{{ trail.name }}</h3> | |
| 154 | <p class="text-sm text-gray-500 dark:text-slate-400 truncate">{{ trail.location }}</p> | |
| 155 | 155 | <div class="flex gap-3 items-center mt-2 text-xs text-gray-500 dark:text-slate-400"> |
| 156 | <span class="capitalize" x-class="'px-2 py-0.5 font-semibold rounded-full difficulty-' + trail.difficulty" :text="trail.difficulty"></span> | |
| 157 | <span :text="trail.distance + ' mi'"></span> | |
| 156 | <span class="capitalize" x-class="'px-2 py-0.5 font-semibold rounded-full difficulty-' + trail.difficulty">{{ trail.difficulty }}</span> | |
| 157 | <span>{{ trail.distance + ' mi' }}</span> | |
| 158 | 158 | <span class="flex items-center gap-1"> |
| 159 | 159 | <Icon name="star" size="12" class="text-yellow-400" /> |
| 160 | <span :text="trail.rating || '—'"></span> | |
| 160 | <span>{{ trail.rating || '—' }}</span> | |
| 161 | 161 | </span> |
| 162 | 162 | </div> |
| 163 | 163 | </StxLink> |
| @@ -68,11 +68,11 @@ | ||
| 68 | 68 | <div class="flex flex-wrap gap-2 items-center"> |
| 69 | 69 | <button @click="targetTerritoryId.set(null)" x-class="!targetTerritoryId ? 'ring-2 ring-emerald-500 bg-emerald-50 dark:bg-emerald-900/20' : 'bg-gray-100 dark:bg-slate-700'" class="px-3 py-1.5 text-xs font-medium rounded-lg transition">Any nearby</button> |
| 70 | 70 | <template :for="zone in attackable"> |
| 71 | <button @click="targetTerritoryId.set(zone.id)" x-class="targetTerritoryId === zone.id ? 'ring-2 ring-orange-500 bg-orange-50 dark:bg-orange-900/20' : 'bg-gray-100 dark:bg-slate-700'" class="px-3 py-1.5 text-xs font-medium rounded-lg transition" x-text="zone.name"></button> | |
| 71 | <button @click="targetTerritoryId.set(zone.id)" x-class="targetTerritoryId === zone.id ? 'ring-2 ring-orange-500 bg-orange-50 dark:bg-orange-900/20' : 'bg-gray-100 dark:bg-slate-700'" class="px-3 py-1.5 text-xs font-medium rounded-lg transition">{{ zone.name }}</button> | |
| 72 | 72 | </template> |
| 73 | 73 | <span :if="attackable.length === 0" class="text-xs text-gray-500 dark:text-slate-400">No enemy zones in range yet — start running near orange turf.</span> |
| 74 | 74 | </div> |
| 75 | <p :if="targetZone" class="mt-2 text-orange-600 text-xs">Focus run: cross <span class="font-semibold" :text="targetZone.name"></span> to fill the capture meter</p> | |
| 75 | <p :if="targetZone" class="mt-2 text-orange-600 text-xs">Focus run: cross <span class="font-semibold">{{ targetZone.name }}</span> to fill the capture meter</p> | |
| 76 | 76 | </div> |
| 77 | 77 | |
| 78 | 78 | <!-- Map --> |
| @@ -81,7 +81,7 @@ | ||
| 81 | 81 | |
| 82 | 82 | <div class="flex absolute right-3 top-3 z-[1000] gap-1.5 items-center px-2.5 py-1 font-medium text-xs rounded-full bg-white/90 backdrop-blur-sm shadow-sm" x-class="gpsStatus === 'active' ? 'text-emerald-700' : gpsStatus === 'searching' ? 'text-yellow-700' : 'text-gray-600'"> |
| 83 | 83 | <div class="h-2 w-2 rounded-full" x-class="gpsStatus === 'active' ? 'bg-emerald-500 animate-pulse' : gpsStatus === 'searching' ? 'bg-yellow-500 animate-pulse' : 'bg-gray-400'"></div> |
| 84 | <span x-text="gpsStatus === 'active' ? (mode === 'simulated' ? 'Simulating' : 'GPS Active') : gpsStatus === 'searching' ? 'Searching...' : 'GPS Off'"></span> | |
| 84 | <span>{{ gpsStatus === 'active' ? (mode === 'simulated' ? 'Simulating' : 'GPS Active') : gpsStatus === 'searching' ? 'Searching...' : 'GPS Off' }}</span> | |
| 85 | 85 | </div> |
| 86 | 86 | |
| 87 | 87 | <div class="absolute bottom-3 left-3 z-[1000] px-3 py-2 bg-white/90 backdrop-blur-sm rounded-lg shadow-sm"> |
| @@ -93,7 +93,7 @@ | ||
| 93 | 93 | </div> |
| 94 | 94 | </div> |
| 95 | 95 | |
| 96 | <div :if="conquestToast" class="capture-toast absolute top-3 left-1/2 -translate-x-1/2 z-[1000] px-5 py-2.5 font-bold text-white bg-emerald-600 rounded-full shadow-xl" x-text="conquestToast"></div> | |
| 96 | <div :if="conquestToast" class="capture-toast absolute top-3 left-1/2 -translate-x-1/2 z-[1000] px-5 py-2.5 font-bold text-white bg-emerald-600 rounded-full shadow-xl">{{ conquestToast }}</div> | |
| 97 | 97 | </div> |
| 98 | 98 | |
| 99 | 99 | <!-- Live capture meters --> |
| @@ -101,8 +101,8 @@ | ||
| 101 | 101 | <template :for="cap in activeCaptures"> |
| 102 | 102 | <div class="card py-3"> |
| 103 | 103 | <div class="flex justify-between mb-1 text-xs"> |
| 104 | <span class="font-semibold dark:text-white" :text="'Capturing ' + cap.name"></span> | |
| 105 | <span class="text-purple-600 font-bold" :text="cap.pct + '%'"></span> | |
| 104 | <span class="font-semibold dark:text-white">{{ 'Capturing ' + cap.name }}</span> | |
| 105 | <span class="text-purple-600 font-bold">{{ cap.pct + '%' }}</span> | |
| 106 | 106 | </div> |
| 107 | 107 | <div class="capture-bar"><div class="capture-bar-fill" :style="'width:' + cap.pct + '%'"></div></div> |
| 108 | 108 | </div> |
| @@ -112,28 +112,28 @@ | ||
| 112 | 112 | <!-- Strava-style stats --> |
| 113 | 113 | <div class="mb-6 card"> |
| 114 | 114 | <div class="mb-4 text-center"> |
| 115 | <p class="font-bold font-mono text-5xl sm:text-6xl tracking-tight dark:text-white" x-text="fmtDuration(elapsed)"></p> | |
| 115 | <p class="font-bold font-mono text-5xl sm:text-6xl tracking-tight dark:text-white">{{ fmtDuration(elapsed) }}</p> | |
| 116 | 116 | <p class="mt-1 metric-label">Elapsed</p> |
| 117 | 117 | </div> |
| 118 | 118 | <div class="grid gap-4 grid-cols-2 sm:grid-cols-5"> |
| 119 | 119 | <div class="strava-stat"> |
| 120 | <p class="font-bold text-2xl dark:text-white" x-text="distance.toFixed(2)"></p> | |
| 120 | <p class="font-bold text-2xl dark:text-white">{{ distance.toFixed(2) }}</p> | |
| 121 | 121 | <p class="metric-label">Miles</p> |
| 122 | 122 | </div> |
| 123 | 123 | <div class="strava-stat"> |
| 124 | <p class="font-bold text-2xl dark:text-white" x-text="distance > 0.01 ? fmtDuration(Math.round(elapsed / distance)) : '--:--'"></p> | |
| 124 | <p class="font-bold text-2xl dark:text-white">{{ distance > 0.01 ? fmtDuration(Math.round(elapsed / distance)) : '--:--' }}</p> | |
| 125 | 125 | <p class="metric-label">Pace</p> |
| 126 | 126 | </div> |
| 127 | 127 | <div class="strava-stat"> |
| 128 | <p class="font-bold text-2xl dark:text-white" x-text="elevation.toLocaleString()"></p> | |
| 128 | <p class="font-bold text-2xl dark:text-white">{{ elevation.toLocaleString() }}</p> | |
| 129 | 129 | <p class="metric-label">Elev ft</p> |
| 130 | 130 | </div> |
| 131 | 131 | <div class="strava-stat"> |
| 132 | <p class="font-bold text-2xl text-emerald-600" x-text="conqueredIds.length"></p> | |
| 132 | <p class="font-bold text-2xl text-emerald-600">{{ conqueredIds.length }}</p> | |
| 133 | 133 | <p class="metric-label">Captured</p> |
| 134 | 134 | </div> |
| 135 | 135 | <div class="strava-stat"> |
| 136 | <p class="font-bold text-2xl text-purple-600" x-text="sessionXp"></p> | |
| 136 | <p class="font-bold text-2xl text-purple-600">{{ sessionXp }}</p> | |
| 137 | 137 | <p class="metric-label">XP</p> |
| 138 | 138 | </div> |
| 139 | 139 | </div> |
| @@ -149,7 +149,7 @@ | ||
| 149 | 149 | <select @change="selectedTrailId.set(Number($event.target.value))" x-disabled="trailOptions.length === 0" class="flex-1 px-3 py-2 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg disabled:opacity-50"> |
| 150 | 150 | <option :if="trailOptions.length === 0" disabled>No trails available yet</option> |
| 151 | 151 | <template :for="opt in trailOptions"> |
| 152 | <option x-value="opt.id" x-selected="opt.id === selectedTrailId" x-text="opt.name"></option> | |
| 152 | <option x-value="opt.id" x-selected="opt.id === selectedTrailId" :text="opt.name"></option> | |
| 153 | 153 | </template> |
| 154 | 154 | </select> |
| 155 | 155 | <button @click="simulate()" x-disabled="trailOptions.length === 0" class="px-4 py-2 font-semibold text-sm text-white bg-sky-600 hover:bg-sky-700 rounded-lg transition whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed">Simulate capture</button> |
| @@ -159,13 +159,13 @@ | ||
| 159 | 159 | <!-- Controls --> |
| 160 | 160 | <div class="flex gap-4 items-center justify-center"> |
| 161 | 161 | <button @click="startManual()" x-class="recording ? 'hidden' : 'record-btn-start'">▶</button> |
| 162 | <button @click="togglePause()" x-class="!recording ? 'hidden' : paused ? 'record-btn-resume' : 'record-btn-pause'" x-text="paused ? '▶' : '⏸'"></button> | |
| 162 | <button @click="togglePause()" x-class="!recording ? 'hidden' : paused ? 'record-btn-resume' : 'record-btn-pause'">{{ paused ? '▶' : '⏸' }}</button> | |
| 163 | 163 | <button @click="stop()" x-class="recording ? 'record-btn-stop' : 'hidden'">⏹</button> |
| 164 | 164 | </div> |
| 165 | 165 | |
| 166 | 166 | <p class="mt-4 text-center text-gray-500 text-sm dark:text-slate-400"> |
| 167 | 167 | <span :if="!recording">Hit ▶ to start GPS capture, or simulate a trail route</span> |
| 168 | <span :if="recording" x-text="runMode === 'capture' ? 'Stay inside enemy zones — capture bar fills as you run' : 'Free run — no territory capture'"></span> | |
| 168 | <span :if="recording">{{ runMode === 'capture' ? 'Stay inside enemy zones — capture bar fills as you run' : 'Free run — no territory capture' }}</span> | |
| 169 | 169 | </p> |
| 170 | 170 | </div> |
| 171 | 171 | @endsection |
| @@ -83,10 +83,10 @@ | ||
| 83 | 83 | </div> |
| 84 | 84 | |
| 85 | 85 | <!-- Error Alert --> |
| 86 | <div :if="error" class="mb-6 p-4 text-red-700 text-sm dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800" :text="error"></div> | |
| 86 | <div :if="error" class="mb-6 p-4 text-red-700 text-sm dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800">{{ error }}</div> | |
| 87 | 87 | |
| 88 | 88 | <!-- Success Alert --> |
| 89 | <div :if="success" class="mb-6 p-4 text-emerald-700 text-sm dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 rounded-xl dark:border-emerald-800" :text="success"></div> | |
| 89 | <div :if="success" class="mb-6 p-4 text-emerald-700 text-sm dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 rounded-xl dark:border-emerald-800">{{ success }}</div> | |
| 90 | 90 | |
| 91 | 91 | <!-- Register Form --> |
| 92 | 92 | <form @submit="handleRegister" class="space-y-5"> |
| @@ -140,7 +140,7 @@ | ||
| 140 | 140 | <div class="overflow-hidden h-1 bg-gray-200 dark:bg-slate-600 rounded-full"> |
| 141 | 141 | <div x-class="passwordStrength().level" class="h-full rounded-full transition-all" x-style="'width: ' + passwordStrength().width"></div> |
| 142 | 142 | </div> |
| 143 | <p x-class="passwordStrength().color" class="mt-1 text-xs" :text="passwordStrength().text"></p> | |
| 143 | <p x-class="passwordStrength().color" class="mt-1 text-xs">{{ passwordStrength().text }}</p> | |
| 144 | 144 | </div> |
| 145 | 145 | </div> |
| 146 | 146 | |
| @@ -35,21 +35,21 @@ | ||
| 35 | 35 | <!-- Summary cards --> |
| 36 | 36 | <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8"> |
| 37 | 37 | <div class="card text-center"> |
| 38 | <p class="text-3xl font-bold text-emerald-600" :text="period === 'weekly' ? userStats.weeklyDistance : period === 'monthly' ? userStats.monthlyDistance : userStats.totalDistance"></p> | |
| 38 | <p class="text-3xl font-bold text-emerald-600">{{ period === 'weekly' ? userStats.weeklyDistance : period === 'monthly' ? userStats.monthlyDistance : userStats.totalDistance }}</p> | |
| 39 | 39 | <p class="metric-label">Miles</p> |
| 40 | 40 | </div> |
| 41 | 41 | <div class="card text-center"> |
| 42 | <p class="text-3xl font-bold text-blue-600" :text="(period === 'weekly' ? userStats.weeklyElevation : period === 'monthly' ? userStats.monthlyElevation : userStats.totalElevation).toLocaleString()"></p> | |
| 42 | <p class="text-3xl font-bold text-blue-600">{{ (period === 'weekly' ? userStats.weeklyElevation : period === 'monthly' ? userStats.monthlyElevation : userStats.totalElevation).toLocaleString() }}</p> | |
| 43 | 43 | <p class="metric-label">Elev (ft)</p> |
| 44 | 44 | </div> |
| 45 | 45 | <div class="card text-center"> |
| 46 | <p class="text-3xl font-bold text-orange-600" :text="period === 'weekly' ? userStats.weeklyActivities : userStats.totalActivities"></p> | |
| 46 | <p class="text-3xl font-bold text-orange-600">{{ period === 'weekly' ? userStats.weeklyActivities : userStats.totalActivities }}</p> | |
| 47 | 47 | <p class="metric-label">Activities</p> |
| 48 | 48 | </div> |
| 49 | 49 | <div class="card text-center"> |
| 50 | <p class="text-3xl font-bold text-purple-600" :text="userStats.currentStreak"></p> | |
| 50 | <p class="text-3xl font-bold text-purple-600">{{ userStats.currentStreak }}</p> | |
| 51 | 51 | <p class="metric-label">Day Streak</p> |
| 52 | <p class="text-xs text-purple-500 mt-1" :text="'Best: ' + userStats.longestStreak + ' days'"></p> | |
| 52 | <p class="text-xs text-purple-500 mt-1">{{ 'Best: ' + userStats.longestStreak + ' days' }}</p> | |
| 53 | 53 | </div> |
| 54 | 54 | </div> |
| 55 | 55 | |
| @@ -59,9 +59,9 @@ | ||
| 59 | 59 | <div :if="userStats.weeklyHistory.length > 0" class="flex items-end gap-3 h-40"> |
| 60 | 60 | <template :for="(week, i) in userStats.weeklyHistory"> |
| 61 | 61 | <div class="flex-1 flex flex-col items-center gap-2"> |
| 62 | <span class="text-xs font-semibold dark:text-white" :text="week.distance.toFixed(1)"></span> | |
| 62 | <span class="text-xs font-semibold dark:text-white">{{ week.distance.toFixed(1) }}</span> | |
| 63 | 63 | <div class="w-full rounded-t-lg transition-all duration-500" x-class="i === userStats.weeklyHistory.length - 1 ? 'bg-emerald-500' : 'bg-emerald-200 dark:bg-emerald-800'" x-style="'height: ' + (week.distance / maxWeeklyDistance * 100) + '%'"></div> |
| 64 | <span class="text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap" :text="week.week"></span> | |
| 64 | <span class="text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">{{ week.week }}</span> | |
| 65 | 65 | </div> |
| 66 | 66 | </template> |
| 67 | 67 | </div> |
| @@ -84,28 +84,28 @@ | ||
| 84 | 84 | <Icon name="zap" size="16" class="text-yellow-500" /> |
| 85 | 85 | Fastest Mile |
| 86 | 86 | </span> |
| 87 | <span class="font-bold text-gray-900 dark:text-white" x-text="userStats.personalRecords.fastestMile + ' /mi'"></span> | |
| 87 | <span class="font-bold text-gray-900 dark:text-white">{{ userStats.personalRecords.fastestMile + ' /mi' }}</span> | |
| 88 | 88 | </div> |
| 89 | 89 | <div class="flex items-center justify-between p-3 bg-emerald-50 dark:bg-emerald-900/10 rounded-xl"> |
| 90 | 90 | <span class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400"> |
| 91 | 91 | <Icon name="footprints" size="16" class="text-emerald-500" /> |
| 92 | 92 | Longest Run |
| 93 | 93 | </span> |
| 94 | <span class="font-bold text-gray-900 dark:text-white" x-text="userStats.personalRecords.longestRun + ' mi'"></span> | |
| 94 | <span class="font-bold text-gray-900 dark:text-white">{{ userStats.personalRecords.longestRun + ' mi' }}</span> | |
| 95 | 95 | </div> |
| 96 | 96 | <div class="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/10 rounded-xl"> |
| 97 | 97 | <span class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400"> |
| 98 | 98 | <Icon name="mountain" size="16" class="text-blue-500" /> |
| 99 | 99 | Biggest Climb |
| 100 | 100 | </span> |
| 101 | <span class="font-bold text-gray-900 dark:text-white" x-text="userStats.personalRecords.biggestClimb.toLocaleString() + ' ft'"></span> | |
| 101 | <span class="font-bold text-gray-900 dark:text-white">{{ userStats.personalRecords.biggestClimb.toLocaleString() + ' ft' }}</span> | |
| 102 | 102 | </div> |
| 103 | 103 | <div class="flex items-center justify-between p-3 bg-orange-50 dark:bg-orange-900/10 rounded-xl"> |
| 104 | 104 | <span class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400"> |
| 105 | 105 | <Icon name="flame" size="16" class="text-orange-500" /> |
| 106 | 106 | Longest Streak |
| 107 | 107 | </span> |
| 108 | <span class="font-bold text-gray-900 dark:text-white" x-text="userStats.personalRecords.longestStreak + ' days'"></span> | |
| 108 | <span class="font-bold text-gray-900 dark:text-white">{{ userStats.personalRecords.longestStreak + ' days' }}</span> | |
| 109 | 109 | </div> |
| 110 | 110 | </div> |
| 111 | 111 | </div> |
| @@ -117,7 +117,7 @@ | ||
| 117 | 117 | <div> |
| 118 | 118 | <div class="flex items-center justify-between mb-1"> |
| 119 | 119 | <span class="text-sm text-gray-600 dark:text-slate-400">Trail Runs</span> |
| 120 | <span class="text-sm font-medium dark:text-white" :text="myActivities.filter((a) => a.activityType === 'Trail Run').length + ' activities'"></span> | |
| 120 | <span class="text-sm font-medium dark:text-white">{{ myActivities.filter((a) => a.activityType === 'Trail Run').length + ' activities' }}</span> | |
| 121 | 121 | </div> |
| 122 | 122 | <div class="h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden"> |
| 123 | 123 | <div class="h-full bg-emerald-500 rounded-full" x-style="'width: ' + (myActivities.filter((a) => a.activityType === 'Trail Run').length / Math.max(myActivities.length, 1) * 100) + '%'"></div> |
| @@ -126,7 +126,7 @@ | ||
| 126 | 126 | <div> |
| 127 | 127 | <div class="flex items-center justify-between mb-1"> |
| 128 | 128 | <span class="text-sm text-gray-600 dark:text-slate-400">Hikes</span> |
| 129 | <span class="text-sm font-medium dark:text-white" :text="myActivities.filter((a) => a.activityType === 'Hike').length + ' activities'"></span> | |
| 129 | <span class="text-sm font-medium dark:text-white">{{ myActivities.filter((a) => a.activityType === 'Hike').length + ' activities' }}</span> | |
| 130 | 130 | </div> |
| 131 | 131 | <div class="h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden"> |
| 132 | 132 | <div class="h-full bg-blue-500 rounded-full" x-style="'width: ' + (myActivities.filter((a) => a.activityType === 'Hike').length / Math.max(myActivities.length, 1) * 100) + '%'"></div> |
| @@ -135,7 +135,7 @@ | ||
| 135 | 135 | <div> |
| 136 | 136 | <div class="flex items-center justify-between mb-1"> |
| 137 | 137 | <span class="text-sm text-gray-600 dark:text-slate-400">Walks</span> |
| 138 | <span class="text-sm font-medium dark:text-white" :text="myActivities.filter((a) => a.activityType === 'Walk').length + ' activities'"></span> | |
| 138 | <span class="text-sm font-medium dark:text-white">{{ myActivities.filter((a) => a.activityType === 'Walk').length + ' activities' }}</span> | |
| 139 | 139 | </div> |
| 140 | 140 | <div class="h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden"> |
| 141 | 141 | <div class="h-full bg-purple-500 rounded-full" x-style="'width: ' + (myActivities.filter((a) => a.activityType === 'Walk').length / Math.max(myActivities.length, 1) * 100) + '%'"></div> |
| @@ -144,7 +144,7 @@ | ||
| 144 | 144 | <div> |
| 145 | 145 | <div class="flex items-center justify-between mb-1"> |
| 146 | 146 | <span class="text-sm text-gray-600 dark:text-slate-400">Bike Rides</span> |
| 147 | <span class="text-sm font-medium dark:text-white" :text="myActivities.filter((a) => a.activityType === 'Bike').length + ' activities'"></span> | |
| 147 | <span class="text-sm font-medium dark:text-white">{{ myActivities.filter((a) => a.activityType === 'Bike').length + ' activities' }}</span> | |
| 148 | 148 | </div> |
| 149 | 149 | <div class="h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden"> |
| 150 | 150 | <div class="h-full bg-orange-500 rounded-full" x-style="'width: ' + (myActivities.filter((a) => a.activityType === 'Bike').length / Math.max(myActivities.length, 1) * 100) + '%'"></div> |
| @@ -153,9 +153,9 @@ | ||
| 153 | 153 | </div> |
| 154 | 154 | |
| 155 | 155 | <div class="mt-6 pt-4 border-t border-gray-100 dark:border-slate-700 space-y-2 text-sm"> |
| 156 | <div class="flex justify-between"><span class="text-gray-500 dark:text-slate-400">Total Time</span><span class="font-bold dark:text-white" :text="userStats.totalTime"></span></div> | |
| 157 | <div class="flex justify-between"><span class="text-gray-500 dark:text-slate-400">Avg Pace</span><span class="font-bold dark:text-white" :text="userStats.avgPace"></span></div> | |
| 158 | <div class="flex justify-between"><span class="text-gray-500 dark:text-slate-400">Total Calories</span><span class="font-bold dark:text-white" :text="userStats.totalCalories.toLocaleString()"></span></div> | |
| 156 | <div class="flex justify-between"><span class="text-gray-500 dark:text-slate-400">Total Time</span><span class="font-bold dark:text-white">{{ userStats.totalTime }}</span></div> | |
| 157 | <div class="flex justify-between"><span class="text-gray-500 dark:text-slate-400">Avg Pace</span><span class="font-bold dark:text-white">{{ userStats.avgPace }}</span></div> | |
| 158 | <div class="flex justify-between"><span class="text-gray-500 dark:text-slate-400">Total Calories</span><span class="font-bold dark:text-white">{{ userStats.totalCalories.toLocaleString() }}</span></div> | |
| 159 | 159 | </div> |
| 160 | 160 | </div> |
| 161 | 161 | </div> |
| @@ -66,20 +66,20 @@ | ||
| 66 | 66 | <template :for="territory in filtered"> |
| 67 | 67 | <StxLink x-to="'/territory/' + territory.id" class="card block hover:shadow-md transition"> |
| 68 | 68 | <div class="flex items-center justify-between mb-3"> |
| 69 | <h3 class="font-bold dark:text-white" :text="territory.name"></h3> | |
| 70 | <span class="px-2 py-0.5 text-xs rounded-full font-medium" x-class="territory.user_id === currentUserId() ? 'bg-emerald-100 text-emerald-700' : territory.status === 'contested' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'" :text="territory.user_id === currentUserId() ? 'Yours' : territory.status === 'contested' ? 'Contested' : 'Enemy'"></span> | |
| 69 | <h3 class="font-bold dark:text-white">{{ territory.name }}</h3> | |
| 70 | <span class="px-2 py-0.5 text-xs rounded-full font-medium" x-class="territory.user_id === currentUserId() ? 'bg-emerald-100 text-emerald-700' : territory.status === 'contested' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'">{{ territory.user_id === currentUserId() ? 'Yours' : territory.status === 'contested' ? 'Contested' : 'Enemy' }}</span> | |
| 71 | 71 | </div> |
| 72 | 72 | <div class="grid grid-cols-3 gap-2 text-center pt-3 border-t border-gray-100 dark:border-slate-700"> |
| 73 | 73 | <div> |
| 74 | <p class="font-bold text-sm text-emerald-600" :text="formatArea(territory.areaSize)"></p> | |
| 74 | <p class="font-bold text-sm text-emerald-600">{{ formatArea(territory.areaSize) }}</p> | |
| 75 | 75 | <p class="metric-label">Size</p> |
| 76 | 76 | </div> |
| 77 | 77 | <div> |
| 78 | <p class="font-bold text-sm dark:text-white" :text="territory.conquestCount"></p> | |
| 78 | <p class="font-bold text-sm dark:text-white">{{ territory.conquestCount }}</p> | |
| 79 | 79 | <p class="metric-label">Conquests</p> |
| 80 | 80 | </div> |
| 81 | 81 | <div> |
| 82 | <p class="font-bold text-sm dark:text-white" :text="territory.defendCount"></p> | |
| 82 | <p class="font-bold text-sm dark:text-white">{{ territory.defendCount }}</p> | |
| 83 | 83 | <p class="metric-label">Defenses</p> |
| 84 | 84 | </div> |
| 85 | 85 | </div> |
| @@ -107,12 +107,12 @@ | ||
| 107 | 107 | <div class="space-y-2"> |
| 108 | 108 | <template :for="entry in rankedLeaderboard"> |
| 109 | 109 | <div class="flex items-center gap-3 p-2 rounded-lg" x-class="entry.user_id === currentUserId() ? 'bg-emerald-50 dark:bg-emerald-900/10' : ''"> |
| 110 | <span class="w-6 text-center font-bold" x-class="entry.rank <= 3 ? 'text-yellow-500' : 'text-gray-400'" :text="entry.rank"></span> | |
| 110 | <span class="w-6 text-center font-bold" x-class="entry.rank <= 3 ? 'text-yellow-500' : 'text-gray-400'">{{ entry.rank }}</span> | |
| 111 | 111 | <div class="flex-1 min-w-0"> |
| 112 | <p class="font-medium text-sm dark:text-white truncate" :text="entry.user_id === currentUserId() ? 'You' : entry.userName"></p> | |
| 113 | <p class="text-xs text-gray-500" :text="formatArea(entry.totalAreaOwned)"></p> | |
| 112 | <p class="font-medium text-sm dark:text-white truncate">{{ entry.user_id === currentUserId() ? 'You' : entry.userName }}</p> | |
| 113 | <p class="text-xs text-gray-500">{{ formatArea(entry.totalAreaOwned) }}</p> | |
| 114 | 114 | </div> |
| 115 | <span class="text-xs font-semibold text-gray-600 dark:text-slate-400" :text="entry.totalTerritoriesOwned + ' owned'"></span> | |
| 115 | <span class="text-xs font-semibold text-gray-600 dark:text-slate-400">{{ entry.totalTerritoriesOwned + ' owned' }}</span> | |
| 116 | 116 | </div> |
| 117 | 117 | </template> |
| 118 | 118 | <div :if="rankedLeaderboard().length === 0" class="py-6 text-center text-sm text-gray-500 dark:text-slate-400"> |
| @@ -56,13 +56,13 @@ | ||
| 56 | 56 | <div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-6"> |
| 57 | 57 | <div> |
| 58 | 58 | <div class="flex gap-3 items-center mb-3"> |
| 59 | <span class="px-3 py-1 font-semibold text-sm rounded-full" x-class="isMine ? 'bg-white/20 text-white' : 'bg-orange-500 text-white'" :text="isMine ? 'Your Territory' : 'Enemy Territory'"></span> | |
| 60 | <span class="px-3 py-1 font-semibold text-sm rounded-full" x-class="territory.status === 'contested' ? 'bg-red-500 text-white' : 'bg-emerald-300/30 text-white'" :text="territory.status === 'contested' ? 'Contested' : 'Secure'"></span> | |
| 59 | <span class="px-3 py-1 font-semibold text-sm rounded-full" x-class="isMine ? 'bg-white/20 text-white' : 'bg-orange-500 text-white'">{{ isMine ? 'Your Territory' : 'Enemy Territory' }}</span> | |
| 60 | <span class="px-3 py-1 font-semibold text-sm rounded-full" x-class="territory.status === 'contested' ? 'bg-red-500 text-white' : 'bg-emerald-300/30 text-white'">{{ territory.status === 'contested' ? 'Contested' : 'Secure' }}</span> | |
| 61 | 61 | </div> |
| 62 | <h1 class="font-bold font-display text-3xl md:text-4xl text-white mb-2" :text="territory.name"></h1> | |
| 63 | <p class="text-lg text-white/70">Owned by <span class="font-semibold text-white" :text="isMine ? 'You' : (owner?.name || 'Unknown')"></span></p> | |
| 62 | <h1 class="font-bold font-display text-3xl md:text-4xl text-white mb-2">{{ territory.name }}</h1> | |
| 63 | <p class="text-lg text-white/70">Owned by <span class="font-semibold text-white">{{ isMine ? 'You' : (owner?.name || 'Unknown') }}</span></p> | |
| 64 | 64 | </div> |
| 65 | <StxLink to="/record" class="px-6 py-3 font-semibold text-sm rounded-xl transition" x-class="isMine ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-white text-orange-700 hover:bg-gray-100 shadow-lg'" :text="isMine ? 'Defend' : 'Conquer This'"></StxLink> | |
| 65 | <StxLink to="/record" class="px-6 py-3 font-semibold text-sm rounded-xl transition" x-class="isMine ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-white text-orange-700 hover:bg-gray-100 shadow-lg'">{{ isMine ? 'Defend' : 'Conquer This' }}</StxLink> | |
| 66 | 66 | </div> |
| 67 | 67 | </div> |
| 68 | 68 | </section> |
| @@ -76,19 +76,19 @@ | ||
| 76 | 76 | |
| 77 | 77 | <div class="grid grid-cols-2 sm:grid-cols-4 gap-4"> |
| 78 | 78 | <div class="card text-center"> |
| 79 | <p class="text-2xl font-bold text-emerald-600" :text="(territory.areaSize / 1000).toFixed(1)"></p> | |
| 79 | <p class="text-2xl font-bold text-emerald-600">{{ (territory.areaSize / 1000).toFixed(1) }}</p> | |
| 80 | 80 | <p class="metric-label">km²</p> |
| 81 | 81 | </div> |
| 82 | 82 | <div class="card text-center"> |
| 83 | <p class="text-2xl font-bold text-blue-600" :text="territory.conquestCount"></p> | |
| 83 | <p class="text-2xl font-bold text-blue-600">{{ territory.conquestCount }}</p> | |
| 84 | 84 | <p class="metric-label">Times Conquered</p> |
| 85 | 85 | </div> |
| 86 | 86 | <div class="card text-center"> |
| 87 | <p class="text-2xl font-bold text-orange-600" :text="territory.defendCount"></p> | |
| 87 | <p class="text-2xl font-bold text-orange-600">{{ territory.defendCount }}</p> | |
| 88 | 88 | <p class="metric-label">Defenses</p> |
| 89 | 89 | </div> |
| 90 | 90 | <div class="card text-center"> |
| 91 | <p class="text-2xl font-bold text-purple-600" :text="territory.totalRunners"></p> | |
| 91 | <p class="text-2xl font-bold text-purple-600">{{ territory.totalRunners }}</p> | |
| 92 | 92 | <p class="metric-label">Runners</p> |
| 93 | 93 | </div> |
| 94 | 94 | </div> |
| @@ -98,15 +98,15 @@ | ||
| 98 | 98 | <div class="space-y-3"> |
| 99 | 99 | <template :for="event in history"> |
| 100 | 100 | <div class="card flex items-center gap-4"> |
| 101 | <div class="w-10 h-10 rounded-full flex items-center justify-center shrink-0 font-bold" x-class="event.status === 'conquered' ? 'bg-orange-100 dark:bg-orange-900/20 text-orange-600' : 'bg-emerald-100 dark:bg-emerald-900/20 text-emerald-600'" :text="event.attackerName.charAt(0)"></div> | |
| 101 | <div class="w-10 h-10 rounded-full flex items-center justify-center shrink-0 font-bold" x-class="event.status === 'conquered' ? 'bg-orange-100 dark:bg-orange-900/20 text-orange-600' : 'bg-emerald-100 dark:bg-emerald-900/20 text-emerald-600'">{{ event.attackerName.charAt(0) }}</div> | |
| 102 | 102 | <div class="flex-1"> |
| 103 | 103 | <p class="text-sm font-semibold dark:text-white"> |
| 104 | <span :text="event.attackerName"></span> | |
| 105 | <span :text="event.status === 'conquered' ? ' conquered this' : ' failed to conquer'"></span> | |
| 104 | <span>{{ event.attackerName }}</span> | |
| 105 | <span>{{ event.status === 'conquered' ? ' conquered this' : ' failed to conquer' }}</span> | |
| 106 | 106 | </p> |
| 107 | <p class="text-xs text-gray-500" :text="tb.timeAgo(event.created_at)"></p> | |
| 107 | <p class="text-xs text-gray-500">{{ tb.timeAgo(event.created_at) }}</p> | |
| 108 | 108 | </div> |
| 109 | <span class="text-xs px-2 py-1 rounded-full font-medium" x-class="event.status === 'conquered' ? 'bg-orange-100 text-orange-700' : 'bg-emerald-100 text-emerald-700'" :text="event.status === 'conquered' ? 'Conquered' : 'Defended'"></span> | |
| 109 | <span class="text-xs px-2 py-1 rounded-full font-medium" x-class="event.status === 'conquered' ? 'bg-orange-100 text-orange-700' : 'bg-emerald-100 text-emerald-700'">{{ event.status === 'conquered' ? 'Conquered' : 'Defended' }}</span> | |
| 110 | 110 | </div> |
| 111 | 111 | </template> |
| 112 | 112 | <div :if="history.length === 0" class="text-center py-8 text-gray-400 text-sm"> |
| @@ -120,10 +120,10 @@ | ||
| 120 | 120 | <div class="card"> |
| 121 | 121 | <h3 class="font-bold text-lg dark:text-white mb-4">Current Owner</h3> |
| 122 | 122 | <div class="flex items-center gap-4"> |
| 123 | <div class="w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 text-xl font-bold" :text="(owner?.name || 'U').charAt(0)"></div> | |
| 123 | <div class="w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 text-xl font-bold">{{ (owner?.name || 'U').charAt(0) }}</div> | |
| 124 | 124 | <div> |
| 125 | <p class="font-bold dark:text-white" :text="isMine ? 'You' : (owner?.name || 'Unknown')"></p> | |
| 126 | <p class="text-sm text-gray-500" :text="'Claimed ' + tb.timeAgo(territory.claimedAt)"></p> | |
| 125 | <p class="font-bold dark:text-white">{{ isMine ? 'You' : (owner?.name || 'Unknown') }}</p> | |
| 126 | <p class="text-sm text-gray-500">{{ 'Claimed ' + tb.timeAgo(territory.claimedAt) }}</p> | |
| 127 | 127 | </div> |
| 128 | 128 | </div> |
| 129 | 129 | </div> |
| @@ -76,11 +76,11 @@ | ||
| 76 | 76 | <div class="relative h-full flex items-end px-4 mx-auto max-w-7xl sm:px-6 lg:px-8 pb-8"> |
| 77 | 77 | <div class="flex-1"> |
| 78 | 78 | <div class="flex items-center gap-2 mb-3"> |
| 79 | <span class="px-2.5 py-1 rounded-full text-xs font-semibold capitalize" x-class="'difficulty-' + trail.difficulty" x-text="trail.difficulty"></span> | |
| 80 | <span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-white/20 text-white" x-text="trail.distance + ' mi'"></span> | |
| 79 | <span class="px-2.5 py-1 rounded-full text-xs font-semibold capitalize" x-class="'difficulty-' + trail.difficulty">{{ trail.difficulty }}</span> | |
| 80 | <span class="px-2.5 py-1 rounded-full text-xs font-semibold bg-white/20 text-white">{{ trail.distance + ' mi' }}</span> | |
| 81 | 81 | </div> |
| 82 | <h1 class="font-display text-3xl md:text-4xl font-bold text-white mb-2" x-text="trail.name"></h1> | |
| 83 | <p class="text-white/70" x-text="trail.location"></p> | |
| 82 | <h1 class="font-display text-3xl md:text-4xl font-bold text-white mb-2">{{ trail.name }}</h1> | |
| 83 | <p class="text-white/70">{{ trail.location }}</p> | |
| 84 | 84 | </div> |
| 85 | 85 | <div class="flex gap-2"> |
| 86 | 86 | <button @click="onToggleSave(trailId())" class="p-3 rounded-xl backdrop-blur-sm transition" x-class="saved ? 'bg-red-500 text-white' : 'bg-white/20 text-white hover:bg-white/30'" x-aria-label="saved ? 'Unsave trail' : 'Save trail'"> |
| @@ -97,30 +97,30 @@ | ||
| 97 | 97 | <!-- Stats --> |
| 98 | 98 | <div class="grid grid-cols-2 sm:grid-cols-4 gap-4"> |
| 99 | 99 | <div class="card text-center"> |
| 100 | <p class="text-2xl font-bold text-emerald-600" x-text="trail.distance"></p> | |
| 100 | <p class="text-2xl font-bold text-emerald-600">{{ trail.distance }}</p> | |
| 101 | 101 | <p class="metric-label">Miles</p> |
| 102 | 102 | </div> |
| 103 | 103 | <div class="card text-center"> |
| 104 | <p class="text-2xl font-bold text-blue-600" x-text="trail.elevation.toLocaleString()"></p> | |
| 104 | <p class="text-2xl font-bold text-blue-600">{{ trail.elevation.toLocaleString() }}</p> | |
| 105 | 105 | <p class="metric-label">Elev (ft)</p> |
| 106 | 106 | </div> |
| 107 | 107 | <div class="card text-center"> |
| 108 | <p class="text-2xl font-bold text-orange-600" x-text="trail.estimatedTime"></p> | |
| 108 | <p class="text-2xl font-bold text-orange-600">{{ trail.estimatedTime }}</p> | |
| 109 | 109 | <p class="metric-label">Est. Time</p> |
| 110 | 110 | </div> |
| 111 | 111 | <div class="card text-center"> |
| 112 | <p class="text-2xl font-bold" x-text="trail.rating || '—'"></p> | |
| 113 | <p class="metric-label" x-text="trail.reviewCount + ' reviews'"></p> | |
| 112 | <p class="text-2xl font-bold">{{ trail.rating || '—' }}</p> | |
| 113 | <p class="metric-label">{{ trail.reviewCount + ' reviews' }}</p> | |
| 114 | 114 | </div> |
| 115 | 115 | </div> |
| 116 | 116 | |
| 117 | 117 | <!-- Description --> |
| 118 | 118 | <div class="card"> |
| 119 | 119 | <h2 class="font-bold text-lg dark:text-white mb-3">About This Trail</h2> |
| 120 | <p class="text-gray-600 dark:text-slate-400 leading-relaxed" x-text="trail.description"></p> | |
| 120 | <p class="text-gray-600 dark:text-slate-400 leading-relaxed">{{ trail.description }}</p> | |
| 121 | 121 | <div class="flex flex-wrap gap-2 mt-4"> |
| 122 | 122 | <template :for="tag in trail.tags"> |
| 123 | <span class="px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full" x-text="tag"></span> | |
| 123 | <span class="px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full">{{ tag }}</span> | |
| 124 | 124 | </template> |
| 125 | 125 | </div> |
| 126 | 126 | </div> |
| @@ -137,16 +137,16 @@ | ||
| 137 | 137 | <template :for="seg in segments"> |
| 138 | 138 | <div class="card flex items-center gap-4"> |
| 139 | 139 | <div class="flex-1 min-w-0"> |
| 140 | <p class="font-semibold text-sm dark:text-white" x-text="seg.name"></p> | |
| 141 | <p class="text-xs text-gray-500 dark:text-slate-400" x-text="seg.distance + ' mi · ' + Math.abs(seg.elevation) + ' ft'"></p> | |
| 140 | <p class="font-semibold text-sm dark:text-white">{{ seg.name }}</p> | |
| 141 | <p class="text-xs text-gray-500 dark:text-slate-400">{{ seg.distance + ' mi · ' + Math.abs(seg.elevation) + ' ft' }}</p> | |
| 142 | 142 | </div> |
| 143 | 143 | <div class="text-right"> |
| 144 | <p class="text-sm font-bold dark:text-white" x-text="seg.yourTime || '—'"></p> | |
| 145 | <p class="text-xs text-gray-500" x-text="seg.yourRank ? '#' + seg.yourRank : 'Not attempted'"></p> | |
| 144 | <p class="text-sm font-bold dark:text-white">{{ seg.yourTime || '—' }}</p> | |
| 145 | <p class="text-xs text-gray-500">{{ seg.yourRank ? '#' + seg.yourRank : 'Not attempted' }}</p> | |
| 146 | 146 | </div> |
| 147 | 147 | <div class="text-right"> |
| 148 | 148 | <p class="text-xs text-yellow-600 font-medium">CR</p> |
| 149 | <p class="text-xs text-gray-500" x-text="seg.leaderTime + ' · ' + seg.leaderName"></p> | |
| 149 | <p class="text-xs text-gray-500">{{ seg.leaderTime + ' · ' + seg.leaderName }}</p> | |
| 150 | 150 | </div> |
| 151 | 151 | </div> |
| 152 | 152 | </template> |
| @@ -156,7 +156,7 @@ | ||
| 156 | 156 | <!-- Reviews --> |
| 157 | 157 | <div> |
| 158 | 158 | <div class="flex gap-1 mb-4 border-b border-gray-200 dark:border-slate-700"> |
| 159 | <button @click="reviewTab.set('reviews')" x-class="reviewTab === 'reviews' ? 'border-emerald-600 text-emerald-600' : 'border-transparent text-gray-500'" class="px-4 py-3 text-sm font-medium border-b-2 transition" x-text="'Reviews (' + reviews.length + ')'"></button> | |
| 159 | <button @click="reviewTab.set('reviews')" x-class="reviewTab === 'reviews' ? 'border-emerald-600 text-emerald-600' : 'border-transparent text-gray-500'" class="px-4 py-3 text-sm font-medium border-b-2 transition">{{ 'Reviews (' + reviews.length + ')' }}</button> | |
| 160 | 160 | <button @click="reviewTab.set('conditions')" x-class="reviewTab === 'conditions' ? 'border-emerald-600 text-emerald-600' : 'border-transparent text-gray-500'" class="px-4 py-3 text-sm font-medium border-b-2 transition">Conditions</button> |
| 161 | 161 | </div> |
| 162 | 162 | |
| @@ -172,7 +172,7 @@ | ||
| 172 | 172 | x-class="star <= formRating ? 'text-yellow-400' : 'text-gray-300 dark:text-slate-600 hover:text-yellow-300'" |
| 173 | 173 | x-aria-label="'Rate ' + star + ' stars'">★</button> |
| 174 | 174 | </template> |
| 175 | <span :if="formRating > 0" class="ml-2 text-xs text-gray-500" :text="formRating + '/5'"></span> | |
| 175 | <span :if="formRating > 0" class="ml-2 text-xs text-gray-500">{{ formRating + '/5' }}</span> | |
| 176 | 176 | </div> |
| 177 | 177 | <div class="flex flex-col sm:flex-row gap-3 mb-3"> |
| 178 | 178 | <textarea |
| @@ -184,13 +184,13 @@ | ||
| 184 | 184 | <select @change="formConditions.set($event.target.value)" class="sm:w-40 px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm h-fit"> |
| 185 | 185 | <option value="">Conditions…</option> |
| 186 | 186 | <template :for="opt in conditionOptions"> |
| 187 | <option x-value="opt" x-selected="opt === formConditions" x-text="opt"></option> | |
| 187 | <option x-value="opt" x-selected="opt === formConditions" :text="opt"></option> | |
| 188 | 188 | </template> |
| 189 | 189 | </select> |
| 190 | 190 | </div> |
| 191 | <p :if="reviewError" class="text-sm text-red-500 mb-2" :text="reviewError"></p> | |
| 191 | <p :if="reviewError" class="text-sm text-red-500 mb-2">{{ reviewError }}</p> | |
| 192 | 192 | <div class="flex items-center gap-3"> |
| 193 | <button @click="submitReview()" class="px-4 py-2 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition" :text="submittingReview ? 'Saving…' : 'Post review'"></button> | |
| 193 | <button @click="submitReview()" class="px-4 py-2 bg-emerald-600 text-white text-sm font-semibold rounded-lg hover:bg-emerald-700 transition">{{ submittingReview ? 'Saving…' : 'Post review' }}</button> | |
| 194 | 194 | <span :if="reviewSaved" class="text-sm text-emerald-600 font-medium">Saved — thanks!</span> |
| 195 | 195 | </div> |
| 196 | 196 | </div> |
| @@ -199,16 +199,16 @@ | ||
| 199 | 199 | <div class="card"> |
| 200 | 200 | <div class="flex items-center justify-between mb-2"> |
| 201 | 201 | <div class="flex items-center gap-3"> |
| 202 | <div class="w-9 h-9 rounded-full bg-emerald-100 dark:bg-emerald-900/20 flex items-center justify-center text-emerald-600 text-sm font-bold" x-text="review.userName.charAt(0)"></div> | |
| 202 | <div class="w-9 h-9 rounded-full bg-emerald-100 dark:bg-emerald-900/20 flex items-center justify-center text-emerald-600 text-sm font-bold">{{ review.userName.charAt(0) }}</div> | |
| 203 | 203 | <div> |
| 204 | <p class="font-semibold text-sm dark:text-white" x-text="review.userName"></p> | |
| 205 | <p class="text-xs text-gray-500" x-text="tb.timeAgo(review.created_at)"></p> | |
| 204 | <p class="font-semibold text-sm dark:text-white">{{ review.userName }}</p> | |
| 205 | <p class="text-xs text-gray-500">{{ tb.timeAgo(review.created_at) }}</p> | |
| 206 | 206 | </div> |
| 207 | 207 | </div> |
| 208 | <span class="text-yellow-400" x-text="'★'.repeat(review.rating)"></span> | |
| 208 | <span class="text-yellow-400">{{ '★'.repeat(review.rating) }}</span> | |
| 209 | 209 | </div> |
| 210 | <p class="text-sm text-gray-600 dark:text-slate-400" x-text="review.text"></p> | |
| 211 | <p :if="review.conditions" class="mt-2 text-xs text-gray-400 capitalize" x-text="'Conditions: ' + review.conditions"></p> | |
| 210 | <p class="text-sm text-gray-600 dark:text-slate-400">{{ review.text }}</p> | |
| 211 | <p :if="review.conditions" class="mt-2 text-xs text-gray-400 capitalize">{{ 'Conditions: ' + review.conditions }}</p> | |
| 212 | 212 | </div> |
| 213 | 213 | </template> |
| 214 | 214 | <div :if="reviews.length === 0" class="text-center py-6 text-gray-400 text-sm"> |
| @@ -218,7 +218,7 @@ | ||
| 218 | 218 | |
| 219 | 219 | <div :if="reviewTab === 'conditions'" class="card"> |
| 220 | 220 | <h3 class="font-semibold text-sm dark:text-white mb-2">Current Conditions</h3> |
| 221 | <p class="text-sm text-gray-600 dark:text-slate-400" x-text="trail.conditions"></p> | |
| 221 | <p class="text-sm text-gray-600 dark:text-slate-400">{{ trail.conditions }}</p> | |
| 222 | 222 | </div> |
| 223 | 223 | </div> |
| 224 | 224 | </div> |
| @@ -228,10 +228,10 @@ | ||
| 228 | 228 | <div class="card"> |
| 229 | 229 | <h3 class="font-bold text-lg dark:text-white mb-4">Trail Info</h3> |
| 230 | 230 | <div class="space-y-3"> |
| 231 | <div class="flex justify-between"><span class="text-sm text-gray-500">Difficulty</span><span class="text-sm font-medium capitalize" x-class="'difficulty-' + trail.difficulty" x-text="trail.difficulty"></span></div> | |
| 232 | <div class="flex justify-between"><span class="text-sm text-gray-500">Distance</span><span class="text-sm font-medium dark:text-white" x-text="trail.distance + ' mi'"></span></div> | |
| 233 | <div class="flex justify-between"><span class="text-sm text-gray-500">Elevation</span><span class="text-sm font-medium dark:text-white" x-text="trail.elevation.toLocaleString() + ' ft'"></span></div> | |
| 234 | <div class="flex justify-between"><span class="text-sm text-gray-500">Time</span><span class="text-sm font-medium dark:text-white" x-text="trail.estimatedTime"></span></div> | |
| 231 | <div class="flex justify-between"><span class="text-sm text-gray-500">Difficulty</span><span class="text-sm font-medium capitalize" x-class="'difficulty-' + trail.difficulty">{{ trail.difficulty }}</span></div> | |
| 232 | <div class="flex justify-between"><span class="text-sm text-gray-500">Distance</span><span class="text-sm font-medium dark:text-white">{{ trail.distance + ' mi' }}</span></div> | |
| 233 | <div class="flex justify-between"><span class="text-sm text-gray-500">Elevation</span><span class="text-sm font-medium dark:text-white">{{ trail.elevation.toLocaleString() + ' ft' }}</span></div> | |
| 234 | <div class="flex justify-between"><span class="text-sm text-gray-500">Time</span><span class="text-sm font-medium dark:text-white">{{ trail.estimatedTime }}</span></div> | |
| 235 | 235 | </div> |
| 236 | 236 | </div> |
| 237 | 237 | |
| @@ -94,8 +94,8 @@ | ||
| 94 | 94 | <div> |
| 95 | 95 | <h1 class="font-bold text-2xl dark:text-white">Explore Trails</h1> |
| 96 | 96 | <p class="text-sm text-gray-500 dark:text-slate-400 mt-1"> |
| 97 | <span :text="trailCount() + ' trails'"></span> | |
| 98 | <span :if="routeCount() > 0"> · <span :text="routeCount() + ' with GPS paths'"></span></span> | |
| 97 | <span>{{ trailCount() + ' trails' }}</span> | |
| 98 | <span :if="routeCount() > 0"> · <span>{{ routeCount() + ' with GPS paths' }}</span></span> | |
| 99 | 99 | <span :if="catalogSource() === 'api'" class="text-emerald-600 dark:text-emerald-400"> · live data</span> |
| 100 | 100 | </p> |
| 101 | 101 | </div> |
| @@ -109,7 +109,7 @@ | ||
| 109 | 109 | </div> |
| 110 | 110 | |
| 111 | 111 | <div :if="catalogLoading()" class="mb-4 text-sm text-emerald-600 dark:text-emerald-400">Loading trail database…</div> |
| 112 | <div :if="catalogError()" class="mb-4 text-sm text-amber-600 dark:text-amber-400" :text="'Using demo trails — ' + catalogError()"></div> | |
| 112 | <div :if="catalogError()" class="mb-4 text-sm text-amber-600 dark:text-amber-400">{{ 'Using demo trails — ' + catalogError() }}</div> | |
| 113 | 113 | |
| 114 | 114 | <!-- Split: trail list + map (Strava-style) --> |
| 115 | 115 | <div class="grid gap-6 mb-6 lg:grid-cols-5"> |
| @@ -131,12 +131,12 @@ | ||
| 131 | 131 | <img x-src="trail.image || 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=200&h=200&fit=crop'" x-alt="trail.name" class="object-cover h-full w-full" /> |
| 132 | 132 | </div> |
| 133 | 133 | <div class="min-w-0 flex-1"> |
| 134 | <p class="font-semibold text-sm dark:text-white truncate" :text="trail.name"></p> | |
| 135 | <p class="text-xs text-gray-500 dark:text-slate-400 truncate" :text="trail.location"></p> | |
| 134 | <p class="font-semibold text-sm dark:text-white truncate">{{ trail.name }}</p> | |
| 135 | <p class="text-xs text-gray-500 dark:text-slate-400 truncate">{{ trail.location }}</p> | |
| 136 | 136 | <p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5"> |
| 137 | <span :text="trail.distance + ' mi'"></span> | |
| 137 | <span>{{ trail.distance + ' mi' }}</span> | |
| 138 | 138 | <span class="mx-1">·</span> |
| 139 | <span x-class="'capitalize difficulty-' + trail.difficulty" :text="trail.difficulty"></span> | |
| 139 | <span x-class="'capitalize difficulty-' + trail.difficulty">{{ trail.difficulty }}</span> | |
| 140 | 140 | </p> |
| 141 | 141 | </div> |
| 142 | 142 | </StxLink> |
| @@ -166,7 +166,7 @@ | ||
| 166 | 166 | <div class="overflow-hidden relative h-48"> |
| 167 | 167 | <img x-src="trail.image || 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=800&h=600&fit=crop'" x-alt="trail.name" class="object-cover h-full w-full duration-300 transition-transform group-hover:scale-105" /> |
| 168 | 168 | <div class="absolute left-3 top-3"> |
| 169 | <span x-class="'px-2 py-1 capitalize font-semibold text-xs rounded-full difficulty-' + trail.difficulty" :text="trail.difficulty"></span> | |
| 169 | <span x-class="'px-2 py-1 capitalize font-semibold text-xs rounded-full difficulty-' + trail.difficulty">{{ trail.difficulty }}</span> | |
| 170 | 170 | </div> |
| 171 | 171 | <button |
| 172 | 172 | @click.stop.prevent="onToggleSave(trail.id)" |
| @@ -178,17 +178,17 @@ | ||
| 178 | 178 | </div> |
| 179 | 179 | <div class="p-4"> |
| 180 | 180 | <div class="flex items-start justify-between mb-1"> |
| 181 | <h3 class="font-bold text-gray-900 dark:text-white group-hover:text-emerald-600 transition" :text="trail.name"></h3> | |
| 181 | <h3 class="font-bold text-gray-900 dark:text-white group-hover:text-emerald-600 transition">{{ trail.name }}</h3> | |
| 182 | 182 | <div class="flex gap-1 items-center shrink-0 text-sm"> |
| 183 | 183 | <Icon name="star" size="16" class="text-yellow-400" /> |
| 184 | <span class="font-semibold" :text="trail.rating || '—'"></span> | |
| 184 | <span class="font-semibold">{{ trail.rating || '—' }}</span> | |
| 185 | 185 | </div> |
| 186 | 186 | </div> |
| 187 | <p class="mb-3 text-gray-500 text-sm dark:text-slate-400" :text="trail.location"></p> | |
| 187 | <p class="mb-3 text-gray-500 text-sm dark:text-slate-400">{{ trail.location }}</p> | |
| 188 | 188 | <div class="flex gap-4 items-center pt-3 text-gray-500 text-xs dark:text-slate-400 border-gray-100 border-t dark:border-slate-700"> |
| 189 | <span :text="trail.distance + ' mi'"></span> | |
| 190 | <span :text="trail.elevation.toLocaleString() + ' ft elev'"></span> | |
| 191 | <span :text="'~' + trail.estimatedTime"></span> | |
| 189 | <span>{{ trail.distance + ' mi' }}</span> | |
| 190 | <span>{{ trail.elevation.toLocaleString() + ' ft elev' }}</span> | |
| 191 | <span>{{ '~' + trail.estimatedTime }}</span> | |
| 192 | 192 | </div> |
| 193 | 193 | </div> |
| 194 | 194 | </StxLink> |
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * Verification harness for numeric column affinity (#974): float-holding | |
| 3 | * columns (lat/lng/area/perimeter/rating/distance/elevation) must have REAL | |
| 4 | * affinity; ids and counts must stay INTEGER; and fractional values must | |
| 5 | * round-trip through a fresh migrate without truncation. | |
| 6 | * | |
| 7 | * Run after a fresh migrate + seed: | |
| 8 | * bun scripts/seed-game-world.ts && bun scripts/verify-column-affinity.ts | |
| 9 | */ | |
| 10 | /* eslint-disable ts/no-top-level-await */ | |
| 11 | import process from 'node:process' | |
| 12 | ||
| 13 | process.on('unhandledRejection', (err) => { console.error('UNHANDLED', err); process.exit(1) }) | |
| 14 | ||
| 15 | import { Database } from 'bun:sqlite' | |
| 16 | ||
| 17 | let failures = 0 | |
| 18 | function check(label: string, ok: boolean, detail?: string) { | |
| 19 | console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` — ${detail}` : ''}`) | |
| 20 | if (!ok) failures++ | |
| 21 | } | |
| 22 | ||
| 23 | const db = new Database('database/stacks.sqlite') | |
| 24 | function colType(table: string, col: string): string { | |
| 25 | const info = db.query(`PRAGMA table_info(${table})`).all() as any[] | |
| 26 | return info.find(c => c.name === col)?.type ?? '(missing)' | |
| 27 | } | |
| 28 | ||
| 29 | // --- REAL float columns ---------------------------------------------------------- | |
| 30 | ||
| 31 | const REAL_COLS: Array<[string, string]> = [ | |
| 32 | ['trails', 'latitude'], ['trails', 'longitude'], ['trails', 'rating'], ['trails', 'distance'], ['trails', 'elevation'], | |
| 33 | ['territories', 'center_lat'], ['territories', 'center_lng'], ['territories', 'area_size'], ['territories', 'perimeter'], | |
| 34 | ['activities', 'distance'], ['activities', 'elevation'], | |
| 35 | ['territory_stats', 'total_area_owned'], ['territory_stats', 'largest_territory_area'], | |
| 36 | ['challenges', 'area_at_stake'], | |
| 37 | ['territory_histories', 'area_at_event'], | |
| 38 | ['user_stats', 'total_distance'], ['user_stats', 'total_elevation'], | |
| 39 | ] | |
| 40 | for (const [t, c] of REAL_COLS) | |
| 41 | check(`${t}.${c} is REAL`, colType(t, c) === 'REAL', colType(t, c)) | |
| 42 | ||
| 43 | // --- INTEGER id/count columns (must NOT have become REAL) ------------------------- | |
| 44 | ||
| 45 | const INT_COLS: Array<[string, string]> = [ | |
| 46 | ['territories', 'user_id'], ['territories', 'activity_id'], ['territories', 'conquest_count'], | |
| 47 | ['activities', 'user_id'], ['activities', 'kudos_count'], | |
| 48 | ['trails', 'review_count'], | |
| 49 | ['territory_stats', 'total_territories_owned'], ['territory_stats', 'territories_conquered'], ['territory_stats', 'weekly_rank'], | |
| 50 | ['challenges', 'challenger_id'], ['challenges', 'winner_id'], | |
| 51 | ['user_stats', 'trails_completed'], ['user_stats', 'current_streak'], ['user_stats', 'total_activities'], | |
| 52 | ['reviews_alias_skip', 'noop'], | |
| 53 | ] | |
| 54 | for (const [t, c] of INT_COLS) { | |
| 55 | if (t === 'reviews_alias_skip') | |
| 56 | continue | |
| 57 | check(`${t}.${c} stays INTEGER`, colType(t, c) === 'INTEGER', colType(t, c)) | |
| 58 | } | |
| 59 | ||
| 60 | // --- fractional values survive a fresh migrate ----------------------------------- | |
| 61 | ||
| 62 | const terr = db.query('SELECT area_size, center_lat, center_lng, perimeter FROM territories WHERE area_size IS NOT NULL LIMIT 1').get() as any | |
| 63 | check('seeded territory floats are fractional (not truncated)', !!terr | |
| 64 | && terr.area_size % 1 !== 0 && terr.center_lat % 1 !== 0, JSON.stringify(terr)) | |
| 65 | ||
| 66 | // Insert a deliberately fractional row and read it back exactly. | |
| 67 | const probe = 123456.789012 | |
| 68 | db.query('INSERT INTO territories (user_id, name, status, area_size, center_lat, center_lng, perimeter) VALUES (1, \'affinity-probe\', \'active\', ?, ?, ?, ?)') | |
| 69 | .run(probe, 12.3456789, -98.7654321, 42.42) | |
| 70 | const back = db.query('SELECT area_size, center_lat FROM territories WHERE name = \'affinity-probe\'').get() as any | |
| 71 | check('fractional value round-trips exactly through REAL', back?.area_size === probe && back?.center_lat === 12.3456789, JSON.stringify(back)) | |
| 72 | check('stored as REAL storage class (not text/int)', | |
| 73 | (db.query('SELECT typeof(area_size) t FROM territories WHERE name = \'affinity-probe\'').get() as any)?.t === 'real') | |
| 74 | db.query('DELETE FROM territories WHERE name = \'affinity-probe\'').run() | |
| 75 | ||
| 76 | console.log(failures === 0 ? '\n✅ all column affinity checks passed' : `\n❌ ${failures} check(s) failed`) | |
| 77 | process.exit(failures === 0 ? 0 : 1) | |
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * Verification harness for list-endpoint pagination metadata (#978): | |
| 3 | * offset/limit params + meta { offset, limit, total, hasMore } on the index | |
| 4 | * endpoints, with correct slicing, total (full match count), and clamping. | |
| 5 | * | |
| 6 | * Run after `bun scripts/seed-game-world.ts`. | |
| 7 | */ | |
| 8 | /* eslint-disable ts/no-top-level-await */ | |
| 9 | import { existsSync } from 'node:fs' | |
| 10 | import process from 'node:process' | |
| 11 | ||
| 12 | process.on('unhandledRejection', (err) => { console.error('UNHANDLED', err); process.exit(1) }) | |
| 13 | ||
| 14 | import { generateAutoImportFiles, injectGlobalAutoImports } from '@stacksjs/server' | |
| 15 | import { path } from '@stacksjs/path' | |
| 16 | import { paginate, readPageParams } from '../resources/functions/pagination' | |
| 17 | ||
| 18 | let failures = 0 | |
| 19 | function check(label: string, ok: boolean, detail?: string) { | |
| 20 | console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` — ${detail}` : ''}`) | |
| 21 | if (!ok) failures++ | |
| 22 | } | |
| 23 | ||
| 24 | // --- Part 1: pure helper --------------------------------------------------------- | |
| 25 | ||
| 26 | const rows = Array.from({ length: 17 }, (_, i) => i) | |
| 27 | const p1 = paginate(rows, { offset: 0, limit: 5 }) | |
| 28 | check('paginate slices the page', p1.items.length === 5 && p1.items[0] === 0) | |
| 29 | check('meta has offset/limit/total/hasMore', p1.meta.offset === 0 && p1.meta.limit === 5 && p1.meta.total === 17 && p1.meta.hasMore === true) | |
| 30 | const p2 = paginate(rows, { offset: 15, limit: 5 }) | |
| 31 | check('last page: partial items, hasMore false', p2.items.length === 2 && p2.meta.hasMore === false) | |
| 32 | const p3 = paginate(rows, { offset: 0, limit: 100 }) | |
| 33 | check('limit ≥ total: all items, hasMore false', p3.items.length === 17 && p3.meta.hasMore === false) | |
| 34 | ||
| 35 | const req = (q: Record<string, any>) => ({ get: (k: string) => q[k] }) | |
| 36 | check('readPageParams defaults', JSON.stringify(readPageParams(req({}))) === JSON.stringify({ offset: 0, limit: 50 })) | |
| 37 | check('readPageParams clamps limit to max', readPageParams(req({ limit: 9999 }), { maxLimit: 200 }).limit === 200) | |
| 38 | check('readPageParams rejects junk/negative', readPageParams(req({ limit: 'x', offset: -3 })).offset === 0 | |
| 39 | && readPageParams(req({ limit: 'x' })).limit === 50) | |
| 40 | ||
| 41 | // --- Part 2: live endpoints ------------------------------------------------------ | |
| 42 | ||
| 43 | const modelsIndex = path.storagePath('framework/auto-imports/models.ts') | |
| 44 | if (!existsSync(modelsIndex)) | |
| 45 | await generateAutoImportFiles() | |
| 46 | const RealError = globalThis.Error | |
| 47 | await injectGlobalAutoImports() | |
| 48 | globalThis.Error = RealError | |
| 49 | ||
| 50 | const ActivityIndex = (await import('../app/Actions/Activity/ActivityIndexAction')).default | |
| 51 | const TrailIndex = (await import('../app/Actions/Trail/TrailIndexAction')).default | |
| 52 | const ClubIndex = (await import('../app/Actions/Club/ClubIndexAction')).default | |
| 53 | const UserSearch = (await import('../app/Actions/Social/UserSearchAction')).default | |
| 54 | ||
| 55 | async function call(action: any, body: Record<string, unknown>): Promise<{ status: number, json: any }> { | |
| 56 | const request = { get: (k: string) => (body as any)[k] } | |
| 57 | const res = await action.handle(request) | |
| 58 | let json: any = null | |
| 59 | try { json = await res.clone().json() } | |
| 60 | catch { json = res } | |
| 61 | return { status: res?.status ?? 200, json } | |
| 62 | } | |
| 63 | ||
| 64 | // Activities: full set first to learn the real total. | |
| 65 | const aAll = await call(ActivityIndex, {}) | |
| 66 | const total = aAll.json?.meta?.total | |
| 67 | check('ActivityIndex returns full meta', typeof total === 'number' | |
| 68 | && aAll.json?.meta?.offset === 0 && typeof aAll.json?.meta?.limit === 'number' | |
| 69 | && typeof aAll.json?.meta?.hasMore === 'boolean', JSON.stringify(aAll.json?.meta)) | |
| 70 | check('default page returns all when total < limit', aAll.json?.activities?.length === total && aAll.json?.meta?.hasMore === false) | |
| 71 | ||
| 72 | const aPage = await call(ActivityIndex, { limit: 5, offset: 0 }) | |
| 73 | check('limit=5 → 5 items, hasMore true, total unchanged', aPage.json?.activities?.length === Math.min(5, total) | |
| 74 | && aPage.json?.meta?.total === total && aPage.json?.meta?.hasMore === (total > 5), JSON.stringify(aPage.json?.meta)) | |
| 75 | const aPage2 = await call(ActivityIndex, { limit: 5, offset: 5 }) | |
| 76 | check('offset=5 returns a different slice', aPage.json?.activities?.[0]?.id !== aPage2.json?.activities?.[0]?.id || total <= 5) | |
| 77 | const aLast = await call(ActivityIndex, { limit: 5, offset: total - 2 }) | |
| 78 | check('last partial page has hasMore false', aLast.json?.activities?.length === 2 && aLast.json?.meta?.hasMore === false) | |
| 79 | ||
| 80 | // Trails: was a bare array; now wrapped with meta (backward-compatible reader). | |
| 81 | const tAll = await call(TrailIndex, {}) | |
| 82 | check('TrailIndex now wrapped { trails, meta }', Array.isArray(tAll.json?.trails) && !!tAll.json?.meta | |
| 83 | && typeof tAll.json.meta.total === 'number', JSON.stringify(tAll.json?.meta)) | |
| 84 | const tPage = await call(TrailIndex, { limit: 5 }) | |
| 85 | check('TrailIndex paginates', tPage.json?.trails?.length === Math.min(5, tAll.json.meta.total) | |
| 86 | && tPage.json?.meta?.hasMore === (tAll.json.meta.total > 5)) | |
| 87 | ||
| 88 | // Clubs (anonymous): meta present. | |
| 89 | const cAll = await call(ClubIndex, {}) | |
| 90 | check('ClubIndex has pagination meta', typeof cAll.json?.meta?.offset === 'number' | |
| 91 | && typeof cAll.json?.meta?.hasMore === 'boolean') | |
| 92 | ||
| 93 | // UserSearch: total must be the REAL match count, not capped at the page size | |
| 94 | // (the DB-side .limit() before paginate() was removed, #978 review). | |
| 95 | const sAll = await call(UserSearch, {}) | |
| 96 | const userTotal = sAll.json?.meta?.total | |
| 97 | check('UserSearch total = real match count', typeof userTotal === 'number' && userTotal >= 3 | |
| 98 | && sAll.json?.athletes?.length === userTotal, JSON.stringify(sAll.json?.meta)) | |
| 99 | const sPage = await call(UserSearch, { limit: 2 }) | |
| 100 | check('UserSearch limit=2: 2 items, total unchanged, hasMore true (not capped)', | |
| 101 | sPage.json?.athletes?.length === 2 && sPage.json?.meta?.total === userTotal | |
| 102 | && sPage.json?.meta?.hasMore === (userTotal > 2), JSON.stringify(sPage.json?.meta)) | |
| 103 | ||
| 104 | console.log(failures === 0 ? '\n✅ all pagination checks passed' : `\n❌ ${failures} check(s) failed`) | |
| 105 | process.exit(failures === 0 ? 0 : 1) | |
| @@ -5,6 +5,8 @@ export type { TerritoryRankStatsRow, TerritoryGainEvent, TerritoryRankAssignment | ||
| 5 | 5 | export { increment, count } from '../../../resources/functions/counter' |
| 6 | 6 | export { shapeChallenge } from '../../../resources/functions/challenges' |
| 7 | 7 | export type { ChallengeNames } from '../../../resources/functions/challenges' |
| 8 | export { readPageParams, paginate } from '../../../resources/functions/pagination' | |
| 9 | export type { PageParams, PageMeta } from '../../../resources/functions/pagination' | |
| 8 | 10 | export { longestDayStreak, hasSubSevenMile, computeAchievementProgress } from '../../../resources/functions/achievements' |
| 9 | 11 | export type { AchievementDefinitionRow, AchievementProgressEntry } from '../../../resources/functions/achievements' |
| 10 | 12 | export { extractMentionQuery, mentionCandidates, applyMention, parseMentions } from '../../../resources/functions/mentions' |
| @@ -14,6 +14,8 @@ declare global { | ||
| 14 | 14 | const increment: typeof autoImports.increment |
| 15 | 15 | const count: typeof autoImports.count |
| 16 | 16 | const shapeChallenge: typeof autoImports.shapeChallenge |
| 17 | const readPageParams: typeof autoImports.readPageParams | |
| 18 | const paginate: typeof autoImports.paginate | |
| 17 | 19 | const longestDayStreak: typeof autoImports.longestDayStreak |
| 18 | 20 | const hasSubSevenMile: typeof autoImports.hasSubSevenMile |
| 19 | 21 | const computeAchievementProgress: typeof autoImports.computeAchievementProgress |
| @@ -349,7 +349,7 @@ export function mapFieldTypeToColumnType(validator: ValidationType, driver = 'my | ||
| 349 | 349 | return `'timestamp'` |
| 350 | 350 | |
| 351 | 351 | if (isFloatValidator(validator)) |
| 352 | return `'float4'` | |
| 352 | return driver === 'sqlite' ? `'real'` : `'float4'` // SQLite: REAL affinity for floats (#974) | |
| 353 | 353 | |
| 354 | 354 | // Handle array/object types |
| 355 | 355 | if (['array', 'object'].includes(validator.name)) |