use prettier formatting

This commit is contained in:
2026-03-13 17:07:06 +01:00
parent 6a0bb9061d
commit d597c6545e
45 changed files with 1909 additions and 861 deletions

View File

@@ -12,5 +12,5 @@
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
"tailwindStylesheet": "./src/routes/(app)/layout.css"
}

View File

@@ -25,10 +25,19 @@ const OSU_CLIENT_ID = process.env.OSU_CLIENT_ID!;
const OSU_CLIENT_SECRET = process.env.OSU_CLIENT_SECRET!;
for (const [k, v] of Object.entries({
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,
R2_BUCKET, R2_PUBLIC_URL, DATABASE_URL, OSU_CLIENT_ID, OSU_CLIENT_SECRET
R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_BUCKET,
R2_PUBLIC_URL,
DATABASE_URL,
OSU_CLIENT_ID,
OSU_CLIENT_SECRET
})) {
if (!v) { console.error(`Missing env var: ${k}`); process.exit(1); }
if (!v) {
console.error(`Missing env var: ${k}`);
process.exit(1);
}
}
// ── R2 ─────────────────────────────────────────────────────────────────
@@ -76,7 +85,9 @@ function isOsuUrl(url: string | null): boolean {
}
}
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
// ── osu! API ───────────────────────────────────────────────────────────
@@ -96,7 +107,7 @@ async function getOsuToken(): Promise<string> {
})
});
if (!res.ok) throw new Error(`osu! token failed: ${res.status}`);
const data = await res.json() as { access_token: string; expires_in: number };
const data = (await res.json()) as { access_token: string; expires_in: number };
osuToken = data.access_token;
osuTokenExpiry = Date.now() + data.expires_in * 1000;
return osuToken;
@@ -121,7 +132,7 @@ async function getBeatmap(beatmapId: string): Promise<BeatmapData | null> {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
});
if (!res.ok) return null;
return await res.json() as BeatmapData;
return (await res.json()) as BeatmapData;
} catch {
return null;
}
@@ -137,14 +148,17 @@ const sql = new SQL(DATABASE_URL);
console.log('═══ 1/3: Backfilling mappool slot metadata + covers ═══\n');
const slots = await sql<{ id: string; beatmap_id: string; cover_url: string | null; title: string | null }[]>`
const slots = await sql<
{ id: string; beatmap_id: string; cover_url: string | null; title: string | null }[]
>`
SELECT id, beatmap_id, cover_url, title FROM mappool_slot
WHERE cover_url IS NULL OR title IS NULL
`;
console.log(`Found ${slots.length} slots missing metadata\n`);
let slotOk = 0, slotFail = 0;
let slotOk = 0,
slotFail = 0;
for (const slot of slots) {
process.stdout.write(` beatmap ${slot.beatmap_id} ... `);
const bm = await getBeatmap(slot.beatmap_id);
@@ -181,7 +195,9 @@ for (const slot of slots) {
console.log(`\nSlots: ${slotOk} updated, ${slotFail} failed\n`);
// Also update any slots that have osu! URLs instead of R2 URLs (partially migrated)
const osuUrlSlots = await sql<{ id: string; cover_url: string | null; list_cover_url: string | null }[]>`
const osuUrlSlots = await sql<
{ id: string; cover_url: string | null; list_cover_url: string | null }[]
>`
SELECT id, cover_url, list_cover_url FROM mappool_slot
WHERE (cover_url LIKE '%ppy.sh%' OR list_cover_url LIKE '%ppy.sh%')
`;
@@ -189,8 +205,12 @@ const osuUrlSlots = await sql<{ id: string; cover_url: string | null; list_cover
if (osuUrlSlots.length > 0) {
console.log(`Found ${osuUrlSlots.length} slots with osu! URLs (need R2 proxy)\n`);
for (const slot of osuUrlSlots) {
const newCover = slot.cover_url && isOsuUrl(slot.cover_url) ? await proxyToR2(slot.cover_url) : slot.cover_url;
const newList = slot.list_cover_url && isOsuUrl(slot.list_cover_url) ? await proxyToR2(slot.list_cover_url) : slot.list_cover_url;
const newCover =
slot.cover_url && isOsuUrl(slot.cover_url) ? await proxyToR2(slot.cover_url) : slot.cover_url;
const newList =
slot.list_cover_url && isOsuUrl(slot.list_cover_url)
? await proxyToR2(slot.list_cover_url)
: slot.list_cover_url;
await sql`UPDATE mappool_slot SET cover_url = ${newCover}, list_cover_url = ${newList} WHERE id = ${slot.id}`;
}
console.log('Done re-proxying slot covers\n');
@@ -206,7 +226,8 @@ const users = await sql<{ id: string; image: string | null }[]>`
SELECT id, image FROM "user" WHERE image IS NOT NULL
`;
let avatarOk = 0, avatarSkip = 0;
let avatarOk = 0,
avatarSkip = 0;
for (const u of users) {
if (!isOsuUrl(u.image)) {
avatarSkip++;
@@ -235,7 +256,8 @@ const teams = await sql<{ id: string; avatar_url: string | null }[]>`
SELECT id, avatar_url FROM team WHERE avatar_url IS NOT NULL
`;
let teamOk = 0, teamSkip = 0;
let teamOk = 0,
teamSkip = 0;
for (const t of teams) {
if (!isOsuUrl(t.avatar_url)) {
teamSkip++;

View File

@@ -25,8 +25,7 @@ async function count(query: string): Promise<number> {
async function exec(description: string, deleteSql: string) {
// Count first
const countSql = deleteSql
.replace(/^DELETE FROM/, 'SELECT COUNT(*) as count FROM');
const countSql = deleteSql.replace(/^DELETE FROM/, 'SELECT COUNT(*) as count FROM');
const n = await count(countSql);
if (n === 0) {
@@ -53,9 +52,7 @@ async function main() {
const realUsers = await count(
`SELECT COUNT(*) as count FROM "user" WHERE id NOT LIKE 'seed-user-%'`
);
const seedUsers = await count(
`SELECT COUNT(*) as count FROM "user" WHERE id LIKE 'seed-user-%'`
);
const seedUsers = await count(`SELECT COUNT(*) as count FROM "user" WHERE id LIKE 'seed-user-%'`);
console.log(` Real users: ${realUsers}`);
console.log(` Seed users: ${seedUsers}\n`);
@@ -168,37 +165,23 @@ async function main() {
// 13. Teams
console.log('Phase 13: Teams');
await exec(
'team (owned by seed users)',
`DELETE FROM team WHERE owner_id LIKE 'seed-user-%'`
);
await exec('team (owned by seed users)', `DELETE FROM team WHERE owner_id LIKE 'seed-user-%'`);
// 14. Sessions (cascade from user delete, but clean up explicitly)
console.log('Phase 14: Sessions');
await exec(
'session (from seed users)',
`DELETE FROM session WHERE user_id LIKE 'seed-user-%'`
);
await exec('session (from seed users)', `DELETE FROM session WHERE user_id LIKE 'seed-user-%'`);
// 15. Accounts
console.log('Phase 15: Accounts');
await exec(
'account (from seed users)',
`DELETE FROM account WHERE user_id LIKE 'seed-user-%'`
);
await exec('account (from seed users)', `DELETE FROM account WHERE user_id LIKE 'seed-user-%'`);
// 16. Users (the root)
console.log('Phase 16: Users');
await exec(
'user (seed users)',
`DELETE FROM "user" WHERE id LIKE 'seed-user-%'`
);
await exec('user (seed users)', `DELETE FROM "user" WHERE id LIKE 'seed-user-%'`);
// ── Verify ──
console.log('\n── Verification ──');
const remaining = await count(
`SELECT COUNT(*) as count FROM "user" WHERE id LIKE 'seed-user-%'`
);
const remaining = await count(`SELECT COUNT(*) as count FROM "user" WHERE id LIKE 'seed-user-%'`);
const remainingMatches = await count(
`SELECT COUNT(*) as count FROM match WHERE created_by LIKE 'seed-user-%'`
);

View File

@@ -38,7 +38,9 @@ async function main() {
}
if (flag === '--full') {
await db.execute(sql`DELETE FROM team_member WHERE team_id IN (SELECT id FROM team WHERE is_personal = false)`);
await db.execute(
sql`DELETE FROM team_member WHERE team_id IN (SELECT id FROM team WHERE is_personal = false)`
);
await db.execute(sql`DELETE FROM team WHERE is_personal = false`);
console.log(' Cleared: non-personal teams');

View File

@@ -42,9 +42,30 @@ const mappoolSlot = pgTable('mappool_slot', {
// Sample beatmap IDs (real osu! maps)
const SAMPLE_MAPS = [
{ id: '75', category: 'NM', title: 'Disco Prince', artist: 'Kenji Ninuma', sr: 2.5, bpm: 120 },
{ id: '163', category: 'NM', title: 'Crazy Noisy Bizarre Town', artist: 'The DU', sr: 3.2, bpm: 140 },
{ id: '250', category: 'HD', title: 'Sakura Kagetsu', artist: 'Mutsuhiko Izumi', sr: 4.1, bpm: 165 },
{ id: '306', category: 'HR', title: 'Airman ga Taosenai', artist: 'Team Nekokan', sr: 5.0, bpm: 200 },
{
id: '163',
category: 'NM',
title: 'Crazy Noisy Bizarre Town',
artist: 'The DU',
sr: 3.2,
bpm: 140
},
{
id: '250',
category: 'HD',
title: 'Sakura Kagetsu',
artist: 'Mutsuhiko Izumi',
sr: 4.1,
bpm: 165
},
{
id: '306',
category: 'HR',
title: 'Airman ga Taosenai',
artist: 'Team Nekokan',
sr: 5.0,
bpm: 200
},
{ id: '390', category: 'DT', title: 'Kira Kira Days', artist: 'MOSAIC.WAV', sr: 4.5, bpm: 175 },
{ id: '455', category: 'FM', title: 'FREEDOM DiVE', artist: 'xi', sr: 6.0, bpm: 222 },
{ id: '500', category: 'TB', title: 'Blue Zenith', artist: 'xi', sr: 6.5, bpm: 200 }
@@ -61,10 +82,13 @@ async function main() {
}
// Create a sample mappool
const [pool] = await db.insert(mappool).values({
const [pool] = await db
.insert(mappool)
.values({
name: 'Dev Test Pool',
verifiedAt: new Date()
}).returning();
})
.returning();
console.log(`Created mappool: ${pool.name} (${pool.id})`);
@@ -86,7 +110,9 @@ async function main() {
});
}
console.log(` Added ${SAMPLE_MAPS.length} maps (${[...new Set(SAMPLE_MAPS.map((m) => m.category))].join(', ')})`);
console.log(
` Added ${SAMPLE_MAPS.length} maps (${[...new Set(SAMPLE_MAPS.map((m) => m.category))].join(', ')})`
);
console.log('Done. Sign in via osu! OAuth to create a user and personal team.');
process.exit(0);
}

View File

@@ -258,8 +258,8 @@ function rankToElo(rank: number): number {
function generateSyntheticRank(index: number, total: number): number {
const p = index / total;
if (p < 0.05) return randInt(1000, 10000);
if (p < 0.20) return randInt(10000, 50000);
if (p < 0.60) return randInt(50000, 300000);
if (p < 0.2) return randInt(10000, 50000);
if (p < 0.6) return randInt(50000, 300000);
if (p < 0.85) return randInt(300000, 1000000);
return randInt(1000000, 5000000);
}
@@ -286,20 +286,108 @@ function timer() {
// ── Name generators ─────────────────────────────────────────────────────
const PREFIXES = [
'', '', '', 'xx', 'xX', 'ii', 'oO', 'Xx', '-', '_', 'Mr', 'Dr', 'DJ'
];
const PREFIXES = ['', '', '', 'xx', 'xX', 'ii', 'oO', 'Xx', '-', '_', 'Mr', 'Dr', 'DJ'];
const WORDS = [
'Ace', 'Arrow', 'Blaze', 'Bolt', 'Cat', 'Cloud', 'Comet', 'Crow', 'Crystal', 'Cyber',
'Dark', 'Dawn', 'Demon', 'Diamond', 'Dragon', 'Dream', 'Eagle', 'Echo', 'Edge', 'Elite',
'Ember', 'Fang', 'Fire', 'Flash', 'Fox', 'Frost', 'Ghost', 'Gold', 'Hawk', 'Haze',
'Hero', 'Hunt', 'Ice', 'Iron', 'Jade', 'Jet', 'King', 'Knight', 'Leo', 'Light',
'Luna', 'Lynx', 'Mist', 'Moon', 'Neo', 'Night', 'Nova', 'Omega', 'Onyx', 'Phantom',
'Phoenix', 'Pixel', 'Prime', 'Pulse', 'Rage', 'Rain', 'Raven', 'Rex', 'Rise', 'River',
'Rock', 'Ruby', 'Rush', 'Sage', 'Shade', 'Shadow', 'Silver', 'Sky', 'Snap', 'Snow',
'Solar', 'Soul', 'Spark', 'Star', 'Steel', 'Storm', 'Strike', 'Sun', 'Swift', 'Sword',
'Thunder', 'Tiger', 'Titan', 'Turbo', 'Venom', 'Viper', 'Void', 'Wave', 'Wild', 'Wind',
'Wolf', 'Wrath', 'Zen', 'Zero', 'Zeta', 'Zoom', 'Apex', 'Atlas', 'Aura', 'Blade'
'Ace',
'Arrow',
'Blaze',
'Bolt',
'Cat',
'Cloud',
'Comet',
'Crow',
'Crystal',
'Cyber',
'Dark',
'Dawn',
'Demon',
'Diamond',
'Dragon',
'Dream',
'Eagle',
'Echo',
'Edge',
'Elite',
'Ember',
'Fang',
'Fire',
'Flash',
'Fox',
'Frost',
'Ghost',
'Gold',
'Hawk',
'Haze',
'Hero',
'Hunt',
'Ice',
'Iron',
'Jade',
'Jet',
'King',
'Knight',
'Leo',
'Light',
'Luna',
'Lynx',
'Mist',
'Moon',
'Neo',
'Night',
'Nova',
'Omega',
'Onyx',
'Phantom',
'Phoenix',
'Pixel',
'Prime',
'Pulse',
'Rage',
'Rain',
'Raven',
'Rex',
'Rise',
'River',
'Rock',
'Ruby',
'Rush',
'Sage',
'Shade',
'Shadow',
'Silver',
'Sky',
'Snap',
'Snow',
'Solar',
'Soul',
'Spark',
'Star',
'Steel',
'Storm',
'Strike',
'Sun',
'Swift',
'Sword',
'Thunder',
'Tiger',
'Titan',
'Turbo',
'Venom',
'Viper',
'Void',
'Wave',
'Wild',
'Wind',
'Wolf',
'Wrath',
'Zen',
'Zero',
'Zeta',
'Zoom',
'Apex',
'Atlas',
'Aura',
'Blade'
];
const SUFFIXES = ['', '', '', '_', '-', 'HD', 'osu', 'pp', 'x', 'z', '69', '420', 'YT', 'TTV'];
@@ -313,54 +401,210 @@ function generateUsername(index: number): string {
}
const TEAM_NAMES = [
'Team Absolute', 'Velocity Gaming', 'Rising Phoenix', 'Dark Matter', 'Quantum Flux',
'Storm Chasers', 'Neon Knights', 'Solar Flare', 'Arctic Wolves', 'Thunder Strike',
'Crimson Tide', 'Iron Fortress', 'Shadow Realm', 'Crystal Edge', 'Blazing Stars',
'Night Owls', 'Golden Eagles', 'Frost Bite', 'Wild Cards', 'Steel Legion',
'Moon Riders', 'Star Dust', 'Fire Hawks', 'Ice Breakers', 'Wind Walkers',
'Dragon Scale', 'Raven Wing', 'Silver Bullet', 'Ghost Protocol', 'Prime Time',
'Echo Chamber', 'Volt Force', 'Sky Runners', 'Omega Squad', 'Alpha Pack',
'Zero Gravity', 'Dawn Patrol', 'Pixel Perfect', 'Cyber Monks', 'Neo Tokyo',
'Mist Walkers', 'Pulse Wave', 'Zenith', 'Apex Legends', 'Void Runners',
'Soul Reapers', 'Blade Dancers', 'Turbo Charged', 'Snap Dragons', 'Rush Hour',
'Phantom Force', 'Diamond Dogs', 'Cosmic Rays', 'Hyper Beam', 'Sonic Boom',
'Prism', 'Eclipse', 'Nebula', 'Supernova', 'Horizon',
'Catalyst', 'Entropy', 'Vanguard', 'Sentinel', 'Oracle',
'Maelstrom', 'Tempest', 'Cascade', 'Enigma', 'Paradox',
'Mirage', 'Nexus', 'Vertex', 'Cipher', 'Stratos',
'Helix', 'Spectrum', 'Polaris', 'Inferno', 'Tsunami',
'Rampage', 'Havoc', 'Fury', 'Blitz', 'Reign',
'Rapture', 'Genesis', 'Onslaught', 'Vendetta', 'Oblivion',
'Dominion', 'Ascension', 'Redemption', 'Revolution', 'Evolution',
'Dynasty', 'Empire', 'Republic', 'Alliance', 'Syndicate',
'Collective', 'Foundation', 'Initiative', 'Consortium', 'Federation'
'Team Absolute',
'Velocity Gaming',
'Rising Phoenix',
'Dark Matter',
'Quantum Flux',
'Storm Chasers',
'Neon Knights',
'Solar Flare',
'Arctic Wolves',
'Thunder Strike',
'Crimson Tide',
'Iron Fortress',
'Shadow Realm',
'Crystal Edge',
'Blazing Stars',
'Night Owls',
'Golden Eagles',
'Frost Bite',
'Wild Cards',
'Steel Legion',
'Moon Riders',
'Star Dust',
'Fire Hawks',
'Ice Breakers',
'Wind Walkers',
'Dragon Scale',
'Raven Wing',
'Silver Bullet',
'Ghost Protocol',
'Prime Time',
'Echo Chamber',
'Volt Force',
'Sky Runners',
'Omega Squad',
'Alpha Pack',
'Zero Gravity',
'Dawn Patrol',
'Pixel Perfect',
'Cyber Monks',
'Neo Tokyo',
'Mist Walkers',
'Pulse Wave',
'Zenith',
'Apex Legends',
'Void Runners',
'Soul Reapers',
'Blade Dancers',
'Turbo Charged',
'Snap Dragons',
'Rush Hour',
'Phantom Force',
'Diamond Dogs',
'Cosmic Rays',
'Hyper Beam',
'Sonic Boom',
'Prism',
'Eclipse',
'Nebula',
'Supernova',
'Horizon',
'Catalyst',
'Entropy',
'Vanguard',
'Sentinel',
'Oracle',
'Maelstrom',
'Tempest',
'Cascade',
'Enigma',
'Paradox',
'Mirage',
'Nexus',
'Vertex',
'Cipher',
'Stratos',
'Helix',
'Spectrum',
'Polaris',
'Inferno',
'Tsunami',
'Rampage',
'Havoc',
'Fury',
'Blitz',
'Reign',
'Rapture',
'Genesis',
'Onslaught',
'Vendetta',
'Oblivion',
'Dominion',
'Ascension',
'Redemption',
'Revolution',
'Evolution',
'Dynasty',
'Empire',
'Republic',
'Alliance',
'Syndicate',
'Collective',
'Foundation',
'Initiative',
'Consortium',
'Federation'
];
// Beatmap metadata templates — used to generate pool slots with varied data
const ARTISTS = [
'xi', 'DragonForce', 'UNDEAD CORPORATION', 'Imperial Circus Dead Decadence',
'Camellia', 'FELT', 'Halozy', 'Sound Horizon', 'Reol', 'DECO*27',
'ClariS', 'LiSA', 'Aimer', 'EGOIST', 'supercell', 'Linked Horizon',
'Foreground Eclipse', 'BABYMETAL', 'Hana', 'IOSYS',
'Mafumafu', 'Eve', 'Yorushika', 'YOASOBI', 'Kenshi Yonezu',
'Aqours', 'Roselia', 'Raise A Suilen', 'Pastel*Palettes', 'Poppin Party'
'xi',
'DragonForce',
'UNDEAD CORPORATION',
'Imperial Circus Dead Decadence',
'Camellia',
'FELT',
'Halozy',
'Sound Horizon',
'Reol',
'DECO*27',
'ClariS',
'LiSA',
'Aimer',
'EGOIST',
'supercell',
'Linked Horizon',
'Foreground Eclipse',
'BABYMETAL',
'Hana',
'IOSYS',
'Mafumafu',
'Eve',
'Yorushika',
'YOASOBI',
'Kenshi Yonezu',
'Aqours',
'Roselia',
'Raise A Suilen',
'Pastel*Palettes',
'Poppin Party'
];
const TITLES = [
'FREEDOM DiVE', 'Blue Zenith', 'Uta', 'Image Material', 'Brain Power',
'The Big Black', 'Airman ga Taosenai', 'Kira Kira Days', 'Senbonzakura', 'Harumachi Clover',
'Storytellers', 'Highscore', 'Louder than Steel', 'Snow Drive', 'Night of Knights',
'Yuki no Hana', 'Rainbow Road', 'Galaxy Collapse', 'Last Goodbye', 'Shinbatsu',
'Crystallize', 'Overkill', 'Plasma Gun', 'Time Freeze', 'Diamond',
'United', 'Alchemy', 'Scarlet Rose', 'Infinite Dream', 'Lost Umbrella',
'Through the Fire and Flames', 'Cry Thunder', 'Soldiers of the Wasteland', 'Valley of the Damned', 'Black Fire',
'Akasha', 'Ethereal', 'Chrono Trigger', 'Solar System', 'Phantom Rider'
'FREEDOM DiVE',
'Blue Zenith',
'Uta',
'Image Material',
'Brain Power',
'The Big Black',
'Airman ga Taosenai',
'Kira Kira Days',
'Senbonzakura',
'Harumachi Clover',
'Storytellers',
'Highscore',
'Louder than Steel',
'Snow Drive',
'Night of Knights',
'Yuki no Hana',
'Rainbow Road',
'Galaxy Collapse',
'Last Goodbye',
'Shinbatsu',
'Crystallize',
'Overkill',
'Plasma Gun',
'Time Freeze',
'Diamond',
'United',
'Alchemy',
'Scarlet Rose',
'Infinite Dream',
'Lost Umbrella',
'Through the Fire and Flames',
'Cry Thunder',
'Soldiers of the Wasteland',
'Valley of the Damned',
'Black Fire',
'Akasha',
'Ethereal',
'Chrono Trigger',
'Solar System',
'Phantom Rider'
];
const VERSIONS = [
'Normal', 'Hard', 'Insane', 'Expert', "Expert+", 'Lunatic',
"Someone's Extra", 'Another', 'Extreme', "Lasse's Insane",
'Reform', 'Deluge', 'Overdose', 'Rain', 'Platter',
"Monstrata's Expert", "Sotarks' Insane", "fieryrage's Extra", "Kroytz's Insane",
'Kalibe', 'Cataclysm'
'Normal',
'Hard',
'Insane',
'Expert',
'Expert+',
'Lunatic',
"Someone's Extra",
'Another',
'Extreme',
"Lasse's Insane",
'Reform',
'Deluge',
'Overdose',
'Rain',
'Platter',
"Monstrata's Expert",
"Sotarks' Insane",
"fieryrage's Extra",
"Kroytz's Insane",
'Kalibe',
'Cataclysm'
];
// ── PHASE FUNCTIONS ─────────────────────────────────────────────────────
@@ -431,7 +675,13 @@ async function seedUsers(): Promise<UserData[]> {
await batchInsert(account, accounts);
console.log(` Users + accounts: ${USER_COUNT} (${t()})`);
return users.map((u) => ({ id: u.id, name: u.name, email: u.email, role: u.role, createdAt: u.createdAt }));
return users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
createdAt: u.createdAt
}));
}
async function seedPersonalTeams(users: UserData[]): Promise<TeamData[]> {
@@ -472,7 +722,9 @@ async function seedNonPersonalTeams(users: UserData[]): Promise<TeamData[]> {
for (let i = 0; i < TEAM_COUNT; i++) {
teams.push({
name: TEAM_NAMES[i % TEAM_NAMES.length] + (i >= TEAM_NAMES.length ? ` ${Math.floor(i / TEAM_NAMES.length) + 1}` : ''),
name:
TEAM_NAMES[i % TEAM_NAMES.length] +
(i >= TEAM_NAMES.length ? ` ${Math.floor(i / TEAM_NAMES.length) + 1}` : ''),
isPersonal: false,
ownerId: users[i].id
});
@@ -582,10 +834,15 @@ async function seedMappools(users: UserData[]): Promise<PoolData[]> {
// Generate slots for each pool
const SLOT_TEMPLATE = [
{ cat: 'NM', order: 1 }, { cat: 'NM', order: 2 }, { cat: 'NM', order: 3 },
{ cat: 'HD', order: 1 }, { cat: 'HD', order: 2 },
{ cat: 'HR', order: 1 }, { cat: 'HR', order: 2 },
{ cat: 'DT', order: 1 }, { cat: 'DT', order: 2 },
{ cat: 'NM', order: 1 },
{ cat: 'NM', order: 2 },
{ cat: 'NM', order: 3 },
{ cat: 'HD', order: 1 },
{ cat: 'HD', order: 2 },
{ cat: 'HR', order: 1 },
{ cat: 'HR', order: 2 },
{ cat: 'DT', order: 1 },
{ cat: 'DT', order: 2 },
{ cat: 'FM', order: 1 },
{ cat: 'TB', order: 1 }
];
@@ -833,7 +1090,9 @@ async function seedMatches(
}
// Strip temp fields for insert
const participantRows = allParticipants.map(({ _tempIdx, _matchDataIdx, _side, ...rest }) => rest);
const participantRows = allParticipants.map(
({ _tempIdx, _matchDataIdx, _side, ...rest }) => rest
);
const insertedParticipants = await batchInsert(matchParticipant, participantRows);
// Build players
@@ -851,7 +1110,9 @@ async function seedMatches(
});
}
const playerRows = allPlayers.map(({ _participantInsertIdx, _matchDataIdx, _side, ...rest }) => rest);
const playerRows = allPlayers.map(
({ _participantInsertIdx, _matchDataIdx, _side, ...rest }) => rest
);
const insertedPlayers = await batchInsert(matchParticipantPlayer, playerRows);
// Build a lookup: matchDataIdx + side -> participant ID and player ID
@@ -948,7 +1209,8 @@ async function seedMatches(
winnerParticipantId: gameWinner?.participantId || null,
state: gameState,
startedAt: gameStarted,
finishedAt: gameState === 'FINISHED' ? new Date(gameStarted.getTime() + randInt(2, 8) * 60000) : null,
finishedAt:
gameState === 'FINISHED' ? new Date(gameStarted.getTime() + randInt(2, 8) * 60000) : null,
_scores: [
{ playerId: p1.playerId, ...score1 },
{ playerId: p2.playerId, ...score2 }
@@ -988,8 +1250,7 @@ async function seedMatches(
// Update match winners and participant scores
for (const [idx, winner] of matchWinners) {
const mi = insertedMatches[idx];
await db
.execute(sql`UPDATE match SET winner_id = ${winner.winnerId} WHERE id = ${mi.id}`);
await db.execute(sql`UPDATE match SET winner_id = ${winner.winnerId} WHERE id = ${mi.id}`);
const p1 = participantLookup.get(`${idx}-1`)!;
const p2 = participantLookup.get(`${idx}-2`)!;
@@ -1023,7 +1284,13 @@ async function seedMatches(
async function seedNotifications(users: UserData[]): Promise<void> {
const t = timer();
const types = ['match_created', 'match_finished', 'match_cancelled', 'team_invite', 'match_invite'];
const types = [
'match_created',
'match_finished',
'match_cancelled',
'team_invite',
'match_invite'
];
const rows: any[] = [];
for (const u of users) {

View File

@@ -22,10 +22,12 @@
}
</script>
<svelte:window onclick={(e) => {
<svelte:window
onclick={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[data-profile-menu]')) closeMenu();
}} />
}}
/>
<div class="flex items-center gap-4">
{#if user}
@@ -37,35 +39,57 @@
aria-haspopup="true"
>
{#if user.isRootAdmin}
<span class="rounded px-1.5 py-0.5 text-[10px] font-700 bg-red-500/20 text-red-400">
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400">
ROOT
</span>
{:else if badge}
<span class="rounded px-1.5 py-0.5 text-[10px] font-700 {badge.color}">
<span class="font-700 rounded px-1.5 py-0.5 text-[10px] {badge.color}">
{badge.label}
</span>
{/if}
{#if user.image}
<img src={user.image} alt={user.name} class="h-7 w-7 rounded-full" />
{/if}
<span class="hidden font-500 text-sm text-text-primary sm:inline">{user.name}</span>
<svg class="h-3.5 w-3.5 text-text-secondary transition-transform {menuOpen ? 'rotate-180' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<span class="font-500 hidden text-sm text-text-primary sm:inline">{user.name}</span>
<svg
class="h-3.5 w-3.5 text-text-secondary transition-transform {menuOpen
? 'rotate-180'
: ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if menuOpen}
<div
class="absolute right-0 top-full z-50 mt-1.5 w-44 rounded-md border border-border bg-surface-800 py-1 shadow-lg"
class="absolute top-full right-0 z-50 mt-1.5 w-44 rounded-md border border-border bg-surface-800 py-1 shadow-lg"
>
<a
href="/settings"
onclick={closeMenu}
class="flex items-center gap-2.5 px-3 py-2 text-sm text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</a>
@@ -75,8 +99,18 @@
type="submit"
class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-text-secondary transition-colors hover:bg-surface-700 hover:text-red-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Log out
</button>

View File

@@ -1,10 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import {
queue,
joinQueue,
leaveQueue
} from '$lib/stores/queue.svelte';
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
const user = $derived(page.data?.user);
const activeMatch = $derived(page.data?.activeMatch);
@@ -51,7 +47,9 @@
if (res.ok) {
const match = await res.json();
notifications = notifications.map((n) =>
n.referenceId === referenceId ? { ...n, actionedAt: new Date().toISOString(), read: true } : n
n.referenceId === referenceId
? { ...n, actionedAt: new Date().toISOString(), read: true }
: n
);
window.location.href = `/matches/${match.id}`;
}
@@ -61,7 +59,9 @@
const res = await fetch(`/api/invites/${referenceId}/decline`, { method: 'POST' });
if (res.ok) {
notifications = notifications.map((n) =>
n.referenceId === referenceId ? { ...n, actionedAt: new Date().toISOString(), read: true } : n
n.referenceId === referenceId
? { ...n, actionedAt: new Date().toISOString(), read: true }
: n
);
}
}
@@ -90,19 +90,108 @@
{#snippet navIcon(name: string)}
{#if name === 'dashboard'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect
x="14"
y="14"
width="7"
height="7"
/><rect x="3" y="14" width="7" height="7" /></svg
>
{:else if name === 'matches'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 17.5 3 6V3h3l11.5 11.5"/><path d="m13 19 3.5-3.5"/><path d="m16.5 22 5-5"/><path d="M10 5.5 6 2H3v3l4 4"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
{:else if name === 'mappools'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="18" r="3"/><path d="M11 18V8l9-1v10"/><circle cx="20" cy="17" r="3"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="8" cy="18" r="3" /><path d="M11 18V8l9-1v10" /><circle
cx="20"
cy="17"
r="3"
/></svg
>
{:else if name === 'teams'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path
d="M23 21v-2a4 4 0 0 0-3-3.87"
/><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg
>
{:else if name === 'leaderboard'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="18" y1="20" x2="18" y2="10" /><line x1="12" y1="20" x2="12" y2="4" /><line
x1="6"
y1="20"
x2="6"
y2="14"
/></svg
>
{:else if name === 'settings'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/><circle cx="12" cy="12" r="3" /></svg
>
{:else if name === 'admin'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg
>
{/if}
{/snippet}
@@ -118,13 +207,14 @@
<aside
class="fixed top-0 left-0 z-50 flex h-full w-56 flex-col border-r border-border bg-surface-800 transition-transform duration-200 {open
? 'translate-x-0'
: '-translate-x-full'} lg:translate-x-0 lg:z-40"
: '-translate-x-full'} lg:z-40 lg:translate-x-0"
>
<!-- Logo + Notifications -->
<div class="flex items-center justify-between px-5 py-5">
<a href="/" class="flex items-center gap-2.5">
<img src="/logo.png" alt="Vash Esports" class="h-8 w-8" />
<span class="font-700 font-bold text-base tracking-tight text-text-primary">Vash Esports</span>
<span class="font-700 text-base font-bold tracking-tight text-text-primary">Vash Esports</span
>
</a>
{#if user}
<div class="relative">
@@ -133,9 +223,24 @@
class="relative rounded-md p-1.5 text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary"
aria-label="Notifications"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path
d="M10.3 21a1.94 1.94 0 0 0 3.4 0"
/></svg
>
{#if unreadCount > 0}
<span class="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-700 text-surface-900">{unreadCount > 99 ? '99+' : unreadCount}</span>
<span
class="font-700 absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] text-surface-900"
>{unreadCount > 99 ? '99+' : unreadCount}</span
>
{/if}
</button>
@@ -148,18 +253,24 @@
></button>
<!-- Dropdown -->
<div class="absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-surface-800 shadow-xl">
<div
class="absolute top-full left-0 z-50 mt-2 w-72 rounded-lg border border-border bg-surface-800 shadow-xl"
>
<div class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-xs font-600 text-text-secondary">Notifications</span>
<span class="font-600 text-xs text-text-secondary">Notifications</span>
{#if notifications.some((n) => !n.read)}
<button onclick={markAllRead} class="text-[10px] text-accent hover:underline">Mark all read</button>
<button onclick={markAllRead} class="text-[10px] text-accent hover:underline"
>Mark all read</button
>
{/if}
</div>
<div class="max-h-80 overflow-y-auto">
{#if loadingNotifs}
<div class="px-3 py-6 text-center text-xs text-text-secondary">Loading...</div>
{:else if notifications.length === 0}
<div class="px-3 py-6 text-center text-xs text-text-secondary">No notifications</div>
<div class="px-3 py-6 text-center text-xs text-text-secondary">
No notifications
</div>
{:else}
{#each notifications as n}
<div class="border-b border-border/50 px-3 py-2.5 {n.read ? 'opacity-60' : ''}">
@@ -168,22 +279,26 @@
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent"></span>
{/if}
<div class="min-w-0 flex-1">
<p class="text-xs font-600 text-text-primary">{n.title}</p>
<p class="font-600 text-xs text-text-primary">{n.title}</p>
{#if n.message}
<p class="mt-0.5 text-[11px] text-text-secondary">{n.message}</p>
{/if}
<p class="mt-0.5 text-[10px] text-text-secondary/60">{timeAgo(n.createdAt)}</p>
<p class="mt-0.5 text-[10px] text-text-secondary/60">
{timeAgo(n.createdAt)}
</p>
{#if n.type === 'match_invite' && !n.actionedAt && n.referenceId}
<div class="mt-1.5 flex gap-1.5">
<button
onclick={() => handleAcceptInvite(n.referenceId)}
class="rounded bg-green-500/20 px-2 py-0.5 text-[10px] font-600 text-green-400 transition-colors hover:bg-green-500/30"
>Accept</button>
class="font-600 rounded bg-green-500/20 px-2 py-0.5 text-[10px] text-green-400 transition-colors hover:bg-green-500/30"
>Accept</button
>
<button
onclick={() => handleDeclineInvite(n.referenceId)}
class="rounded bg-red-500/20 px-2 py-0.5 text-[10px] font-600 text-red-400 transition-colors hover:bg-red-500/30"
>Decline</button>
class="font-600 rounded bg-red-500/20 px-2 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/30"
>Decline</button
>
</div>
{/if}
</div>
@@ -203,7 +318,7 @@
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
<a
href="/matches/{activeMatch.id}"
class="flex w-full items-center justify-center gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2.5 text-sm font-600 text-yellow-300 transition-colors hover:bg-yellow-500/20"
class="font-600 flex w-full items-center justify-center gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2.5 text-sm text-yellow-300 transition-colors hover:bg-yellow-500/20"
>
<div class="relative h-2 w-2">
<div class="absolute inset-0 animate-ping rounded-full bg-yellow-400 opacity-75"></div>
@@ -213,7 +328,9 @@
</a>
{:else if queue.status?.inQueue}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-center gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2.5 text-sm font-600 text-yellow-300">
<div
class="font-600 flex items-center justify-center gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2.5 text-sm text-yellow-300"
>
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400"></span>
In Queue ({queue.status.queueSize})
</div>
@@ -229,9 +346,21 @@
<button
onclick={joinQueue}
disabled={queue.loading}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
class="font-600 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 17.5 3 6V3h3l11.5 11.5"/><path d="m13 19 3.5-3.5"/><path d="m16.5 22 5-5"/><path d="M10 5.5 6 2H3v3l4 4"/></svg>
{queue.loading ? 'Joining...' : 'Find Match'}
</button>
{/if}
@@ -239,16 +368,12 @@
<!-- Nav -->
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
{#each [
{ href: '/', label: 'Dashboard', icon: 'dashboard' },
{ href: '/matches', label: 'Matches', icon: 'matches' },
{ href: '/mappools', label: 'Mappools', icon: 'mappools' },
{ href: '/teams', label: 'Teams', icon: 'teams' },
{ href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' },
] as item (item.href)}
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
<a
href={item.href}
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(item.href)
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
item.href
)
? 'bg-accent-dim text-accent'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
@@ -259,11 +384,13 @@
{#if isAdmin}
<div class="mt-4 mb-1 px-3">
<p class="text-[10px] font-600 text-text-secondary uppercase tracking-widest">Staff</p>
<p class="font-600 text-[10px] tracking-widest text-text-secondary uppercase">Staff</p>
</div>
<a
href="/admin"
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive('/admin')
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
'/admin'
)
? 'bg-red-500/10 text-red-400'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
@@ -271,30 +398,71 @@
Admin
</a>
{/if}
</nav>
<!-- Bottom -->
<div class="border-t border-border p-3">
<!-- Social links -->
<div class="mb-1 flex items-center gap-1 px-3 py-2">
<a href="https://x.com/vashesports" target="_blank" rel="noopener" aria-label="X / Twitter" class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
<a
href="https://x.com/vashesports"
target="_blank"
rel="noopener"
aria-label="X / Twitter"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/></svg
>
</a>
<a href="https://www.youtube.com/@vash-esports" target="_blank" rel="noopener" aria-label="YouTube" class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
<a
href="https://www.youtube.com/@vash-esports"
target="_blank"
rel="noopener"
aria-label="YouTube"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
/></svg
>
</a>
<a href="https://www.twitch.tv/vashesports" target="_blank" rel="noopener" aria-label="Twitch" class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/></svg>
<a
href="https://www.twitch.tv/vashesports"
target="_blank"
rel="noopener"
aria-label="Twitch"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"
/></svg
>
</a>
<a href="https://discord.gg/n3mZgWk" target="_blank" rel="noopener" aria-label="Discord" class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.108.12 18.156.15 18.197a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<a
href="https://discord.gg/n3mZgWk"
target="_blank"
rel="noopener"
aria-label="Discord"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.108.12 18.156.15 18.197a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/></svg
>
</a>
</div>
<a
href="/settings"
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive('/settings')
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
'/settings'
)
? 'bg-accent-dim text-accent'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
@@ -302,8 +470,15 @@
Settings
</a>
<div class="mt-2 flex gap-3 px-3 pb-1">
<a href="/terms" class="text-xs text-text-secondary/50 transition-colors hover:text-text-secondary">Terms</a>
<a href="/privacy" class="text-xs text-text-secondary/50 transition-colors hover:text-text-secondary">Privacy</a>
<a
href="/terms"
class="text-xs text-text-secondary/50 transition-colors hover:text-text-secondary">Terms</a
>
<a
href="/privacy"
class="text-xs text-text-secondary/50 transition-colors hover:text-text-secondary"
>Privacy</a
>
</div>
</div>
</aside>

View File

@@ -31,9 +31,7 @@ export async function getClient() {
const banchoModule = await import('bancho.js');
const BanchoClient =
banchoModule.BanchoClient ??
banchoModule.default?.BanchoClient ??
banchoModule.default;
banchoModule.BanchoClient ?? banchoModule.default?.BanchoClient ?? banchoModule.default;
client = new BanchoClient({
username: env.OSU_IRC_USERNAME,
@@ -178,10 +176,7 @@ export class TournamentLobby {
const c = await getClient();
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Lobby creation timed out')),
15000
);
const timeout = setTimeout(() => reject(new Error('Lobby creation timed out')), 15000);
const banchoBot = c.getUser('BanchoBot');
@@ -218,7 +213,9 @@ export class TournamentLobby {
if (this.channel && this.messageHandler) {
try {
this.channel.removeListener('message', this.messageHandler);
} catch { /* old channel may be dead */ }
} catch {
/* old channel may be dead */
}
}
this.channel = c.getChannel(this.channelName);
@@ -233,7 +230,9 @@ export class TournamentLobby {
if (this.messageHandler) {
try {
this.channel.removeListener('message', this.messageHandler);
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
this.messageHandler = (msg: any) => {
@@ -254,9 +253,7 @@ export class TournamentLobby {
private parseBanchoMessage(text: string) {
// Player score: "<user> finished playing (Score: 1,234,567, ... PASSED)"
const scoreMatch = text.match(
/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i
);
const scoreMatch = text.match(/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i);
if (scoreMatch) {
this.collectedScores.push({
username: scoreMatch[1].trim(),

View File

@@ -71,7 +71,10 @@ export async function initDMHandler() {
await handleDecline(ircUsername, args[0]);
break;
default:
await sendDM(ircUsername, `Unknown command: ${command}. Type !help for available commands.`);
await sendDM(
ircUsername,
`Unknown command: ${command}. Type !help for available commands.`
);
}
} catch (err: any) {
console.error(`[DM] Error handling "${text}" from ${ircUsername}:`, err.message);
@@ -123,7 +126,10 @@ async function handleQueue(ircUsername: string) {
// Check if already in queue
const status = await getQueueStatus(u.id);
if (status.inQueue) {
await sendDM(ircUsername, `You're already in queue (${status.queueSize} searching). Type !leave to exit.`);
await sendDM(
ircUsername,
`You're already in queue (${status.queueSize} searching). Type !leave to exit.`
);
return;
}
@@ -213,7 +219,10 @@ async function handleInvites(ircUsername: string) {
return `[${shortId}] ${i.creatorTeam.name} — BO${config.bestOf} ${config.scoringType}`;
});
await sendDM(ircUsername, `Pending invites (${pending.length}): ${lines.join(' | ')}. Reply !accept <id> or !decline <id>`);
await sendDM(
ircUsername,
`Pending invites (${pending.length}): ${lines.join(' | ')}. Reply !accept <id> or !decline <id>`
);
}
async function handleAccept(ircUsername: string, inviteIdPrefix?: string) {
@@ -232,7 +241,10 @@ async function handleAccept(ircUsername: string, inviteIdPrefix?: string) {
}
const match = await acceptInvite(invite.id, u.id);
await sendDM(ircUsername, `Accepted! Match created. Check osu! for the lobby invite or visit the web UI.`);
await sendDM(
ircUsername,
`Accepted! Match created. Check osu! for the lobby invite or visit the web UI.`
);
}
async function handleDecline(ircUsername: string, inviteIdPrefix?: string) {

View File

@@ -182,10 +182,7 @@ export const matchQueue = pgTable(
elo: integer('elo').default(1000).notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
uniqueIndex('idx_queue_user').on(t.userId),
index('idx_queue_elo').on(t.elo)
]
(t) => [uniqueIndex('idx_queue_user').on(t.userId), index('idx_queue_elo').on(t.elo)]
);
// ── Player ELO ──────────────────────────────────────────────────────────
@@ -201,7 +198,9 @@ export const playerRating = pgTable('player_rating', {
});
// ── Notifications ────────────────────────────────────────────────────
export const notification = pgTable('notification', {
export const notification = pgTable(
'notification',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
type: text('type').notNull(),
@@ -211,19 +210,29 @@ export const notification = pgTable('notification', {
read: boolean('read').default(false).notNull(),
actionedAt: timestamp('actioned_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
}, (t) => [
},
(t) => [
index('idx_notification_user').on(t.userId),
index('idx_notification_user_read').on(t.userId, t.read)
]);
]
);
// ── Match Invites ────────────────────────────────────────────────────
export const matchInvite = pgTable('match_invite', {
export const matchInvite = pgTable(
'match_invite',
{
id: uuid('id').primaryKey().defaultRandom(),
createdBy: text('created_by').notNull(),
creatorTeamId: uuid('creator_team_id').notNull().references(() => team.id),
invitedTeamId: uuid('invited_team_id').notNull().references(() => team.id),
creatorTeamId: uuid('creator_team_id')
.notNull()
.references(() => team.id),
invitedTeamId: uuid('invited_team_id')
.notNull()
.references(() => team.id),
config: jsonb('config').notNull(),
mappoolId: uuid('mappool_id').notNull().references(() => mappool.id),
mappoolId: uuid('mappool_id')
.notNull()
.references(() => mappool.id),
name: text('name'),
message: text('message'),
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
@@ -232,11 +241,13 @@ export const matchInvite = pgTable('match_invite', {
matchId: uuid('match_id').references(() => match.id),
respondedAt: timestamp('responded_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
}, (t) => [
},
(t) => [
index('idx_invite_invited_team').on(t.invitedTeamId),
index('idx_invite_created_by').on(t.createdBy),
index('idx_invite_status').on(t.status)
]);
]
);
// ── Relations ───────────────────────────────────────────────────────────
@@ -293,11 +304,19 @@ export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
}));
export const notificationRelations = relations(notification, ({ }) => ({}));
export const notificationRelations = relations(notification, ({}) => ({}));
export const matchInviteRelations = relations(matchInvite, ({ one }) => ({
creatorTeam: one(team, { fields: [matchInvite.creatorTeamId], references: [team.id], relationName: 'inviteCreatorTeam' }),
invitedTeam: one(team, { fields: [matchInvite.invitedTeamId], references: [team.id], relationName: 'inviteInvitedTeam' }),
creatorTeam: one(team, {
fields: [matchInvite.creatorTeamId],
references: [team.id],
relationName: 'inviteCreatorTeam'
}),
invitedTeam: one(team, {
fields: [matchInvite.invitedTeamId],
references: [team.id],
relationName: 'inviteInvitedTeam'
}),
mappool: one(mappool, { fields: [matchInvite.mappoolId], references: [mappool.id] }),
match: one(match, { fields: [matchInvite.matchId], references: [match.id] })
}));

View File

@@ -87,10 +87,7 @@ export async function moveToRolling(matchId: string) {
const m = await getMatchOrThrow(matchId);
assertState(m.state, MATCH_STATES.LOBBY);
await db
.update(match)
.set({ state: MATCH_STATES.ROLLING })
.where(eq(match.id, matchId));
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
return getMatchFull(matchId);
}
@@ -110,8 +107,8 @@ export async function submitRoll(matchId: string, participantId: string, value:
where: eq(matchParticipant.matchId, matchId)
});
const allRolled = participants.every(
(p) => (p.id === participantId ? true : p.rollValue !== null)
const allRolled = participants.every((p) =>
p.id === participantId ? true : p.rollValue !== null
);
if (allRolled) {
@@ -142,10 +139,7 @@ export async function submitRoll(matchId: string, participantId: string, value:
.where(eq(matchParticipant.id, sorted[i].id));
}
await db
.update(match)
.set({ state: MATCH_STATES.PICKING })
.where(eq(match.id, matchId));
await db.update(match).set({ state: MATCH_STATES.PICKING }).where(eq(match.id, matchId));
}
}
@@ -206,10 +200,7 @@ export async function pickMap(matchId: string, participantId: string, mappoolSlo
})
.returning();
await db
.update(match)
.set({ state: MATCH_STATES.PLAYING })
.where(eq(match.id, matchId));
await db.update(match).set({ state: MATCH_STATES.PLAYING }).where(eq(match.id, matchId));
return game;
}
@@ -324,10 +315,7 @@ export async function submitGameScores(
const finishedMatch = await getMatchFull(game.matchId);
notifyMatchFinished(finishedMatch).catch(() => {});
} else {
await db
.update(match)
.set({ state: MATCH_STATES.PICKING })
.where(eq(match.id, game.matchId));
await db.update(match).set({ state: MATCH_STATES.PICKING }).where(eq(match.id, game.matchId));
}
return getMatchFull(game.matchId);

View File

@@ -20,7 +20,8 @@ export async function createInvite(opts: {
message?: string;
scheduledAt?: Date | null;
}) {
const { createdBy, creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = opts;
const { createdBy, creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } =
opts;
if (creatorTeamId === invitedTeamId && !dev) {
throw new Error('Cannot invite your own team');

View File

@@ -76,7 +76,9 @@ async function timeoutMatch(matchId: string, reason: string) {
await lobby.chat(`⏰ Match cancelled — ${reason}`);
await sleep(3000);
await lobby.close();
} catch { /* lobby may already be dead */ }
} catch {
/* lobby may already be dead */
}
}
removeLobby(matchId);
@@ -105,14 +107,8 @@ export function onMatchStateChange(matchId: string, newState: string) {
const joined = joinedPlayers.get(matchId) ?? new Set();
const expected = expectedPlayers.get(matchId) ?? new Set();
const missing = [...expected].filter((p) => !joined.has(p));
const detail =
missing.length > 0
? `Missing: ${missing.join(', ')}.`
: '';
await timeoutMatch(
matchId,
`Not all players joined within 5 minutes. ${detail}`
);
const detail = missing.length > 0 ? `Missing: ${missing.join(', ')}.` : '';
await timeoutMatch(matchId, `Not all players joined within 5 minutes. ${detail}`);
});
break;
@@ -140,7 +136,9 @@ export function onMatchStateChange(matchId: string, newState: string) {
for (const p of unrolled) {
const autoRoll = Math.floor(Math.random() * 100) + 1;
await submitRoll(matchId, p.id, autoRoll);
console.log(`[Timeout] Auto-rolled ${autoRoll} for ${p.team.name} in match ${matchId}`);
console.log(
`[Timeout] Auto-rolled ${autoRoll} for ${p.team.name} in match ${matchId}`
);
if (lobby?.isAlive) {
await lobby.chat(`${p.team.name} auto-rolled ${autoRoll}`);
}
@@ -224,7 +222,9 @@ export function onMatchStateChange(matchId: string, newState: string) {
const slotLabel = `${randomSlot.category}${randomSlot.orderInCategory}`;
if (lobby?.isAlive) {
await lobby.chat(`⏰ Pick timed out — auto-picking ${slotLabel} for ${picker?.team.name}`);
await lobby.chat(
`⏰ Pick timed out — auto-picking ${slotLabel} for ${picker?.team.name}`
);
}
console.log(`[Timeout] Auto-picked ${slotLabel} for match ${matchId}`);
@@ -287,9 +287,12 @@ export async function initMatchLobby(matchId: string) {
// Configure: HeadToHead, correct score mode, correct size
const scoreMode =
config.scoringType === 'score_v2' ? 3
: config.scoringType === 'accuracy' ? 1
: config.scoringType === 'combo' ? 2
config.scoringType === 'score_v2'
? 3
: config.scoringType === 'accuracy'
? 1
: config.scoringType === 'combo'
? 2
: 0;
await lobby.setProperties(0, scoreMode, config.teamSize * 2);
await sleep(500);
@@ -330,7 +333,6 @@ export async function initMatchLobby(matchId: string) {
* We also greet each player individually when they join, telling them the current state.
*/
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
// ── Welcome players when they JOIN the channel ──────────────────────
lobby.onPlayerJoined = async (username: string) => {
try {
@@ -374,7 +376,9 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
await lobby.chat(`Welcome ${username}! Waiting for rolls to finish...`);
}
} else if (m.state === MATCH_STATES.PICKING) {
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const sorted = [...m.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const nextIdx = m.games.length % sorted.length;
const picker = sorted[nextIdx];
const scoreStr = `(${sorted[0]?.team.name}) ${sorted[0]?.score} - ${sorted[1]?.score} (${sorted[1]?.team.name})`;
@@ -390,9 +394,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
`Welcome back ${username}! A game is in progress — ready up when it finishes.`
);
} else {
await lobby.chat(
`Welcome back ${username}! Please ready up so the game can start.`
);
await lobby.chat(`Welcome back ${username}! Please ready up so the game can start.`);
}
}
} catch (err: any) {
@@ -497,9 +499,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
const category = labelMatch[1];
const order = parseInt(labelMatch[2]);
const sorted = [...m.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const expectedIdx = m.games.length % sorted.length;
const expectedPicker = sorted[expectedIdx];
@@ -640,10 +640,10 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
} catch (err: any) {
console.warn('[Orchestrator] Ready timeout:', err.message);
try {
await lobby.chat(
'Timed out waiting for ready. Game will force start soon!'
);
} catch { /* lobby may be dead */ }
await lobby.chat('Timed out waiting for ready. Game will force start soon!');
} catch {
/* lobby may be dead */
}
// Still set up score collection — force start will trigger the game
collectScores(matchId, matchGameId, lobby).catch((err) =>
console.error('[Orchestrator] Score collection failed:', err)
@@ -781,7 +781,7 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
s.pp = osuScore.pp ?? null;
if (osuScore.mods?.length) {
s.mods = osuScore.mods.map((mod: any) =>
typeof mod === 'string' ? mod : mod.acronym ?? mod
typeof mod === 'string' ? mod : (mod.acronym ?? mod)
);
}
}
@@ -826,7 +826,9 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
await lobby.chat(
`(${ps1?.team.name}) ${score1} - ${score2} (${ps2?.team.name}) — ${winner?.team.name ?? '?'} wins ${slotLabel}!`
);
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
const matchScoreStr = `(${ps1?.team.name}) ${ps1?.score} - ${ps2?.score} (${ps2?.team.name})`;
@@ -844,7 +846,9 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
await lobby.chat(`This lobby will close in 3 minutes.`);
await sleep(3 * 60 * 1000);
await lobby.close();
} catch { /* lobby may already be closed */ }
} catch {
/* lobby may already be closed */
}
removeLobby(matchId);
expectedPlayers.delete(matchId);
joinedPlayers.delete(matchId);
@@ -864,10 +868,10 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
const tbLabel = `TB${tbSlots[0].orderInCategory}`;
try {
await sleep(1000);
await lobby.chat(
`🔥 Tiebreaker! ${matchScoreStr} — auto-picking ${tbLabel}!`
);
} catch { /* ignore */ }
await lobby.chat(`🔥 Tiebreaker! ${matchScoreStr} — auto-picking ${tbLabel}!`);
} catch {
/* ignore */
}
const game = await pickMap(matchId, picker!.id, tbSlots[0].id);
playPickedMap(matchId, game.id).catch((err) =>
@@ -882,10 +886,7 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
.set({ rollValue: null, pickOrder: null })
.where(eq(matchParticipant.id, p.id));
}
await db
.update(match)
.set({ state: MATCH_STATES.ROLLING })
.where(eq(match.id, matchId));
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
onMatchStateChange(matchId, MATCH_STATES.ROLLING);
@@ -895,7 +896,9 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
`🔥 Tiebreaker! ${matchScoreStr} — ${tbSlots.length} tiebreaker maps available. ` +
`Roll to decide who picks! Type !roll. You have 5 minutes.`
);
} catch { /* ignore */ }
} catch {
/* ignore */
}
return;
}
// Zero unplayed TBs at match point — fall through to normal picking
@@ -911,7 +914,9 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
await lobby.chat(
`${matchScoreStr} — first to ${winsNeeded} — ${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (2 min to pick)`
);
} catch { /* lobby may be dead */ }
} catch {
/* lobby may be dead */
}
}
}

View File

@@ -12,7 +12,9 @@ async function purgeExpiredQueueEntries() {
const deleted = await db.delete(matchQueue).where(lt(matchQueue.joinedAt, cutoff)).returning();
if (deleted.length > 0) {
console.log(`[Queue] Purged ${deleted.length} expired queue entr${deleted.length === 1 ? 'y' : 'ies'}`);
console.log(
`[Queue] Purged ${deleted.length} expired queue entr${deleted.length === 1 ? 'y' : 'ies'}`
);
}
}
@@ -52,9 +54,7 @@ export async function getQueueStatus(userId: string) {
where: eq(matchQueue.userId, userId)
});
const count = await db
.select({ count: sql<number>`count(*)` })
.from(matchQueue);
const count = await db.select({ count: sql<number>`count(*)` }).from(matchQueue);
// If NOT in queue, check if there's a recent active match for this user
// (they may have been matched while polling)
@@ -112,7 +112,14 @@ async function tryMatchFromQueue() {
const [{ activeCount }] = await db
.select({ activeCount: sql<number>`count(*)` })
.from(match)
.where(inArray(match.state, [MATCH_STATES.LOBBY, MATCH_STATES.ROLLING, MATCH_STATES.PICKING, MATCH_STATES.PLAYING]));
.where(
inArray(match.state, [
MATCH_STATES.LOBBY,
MATCH_STATES.ROLLING,
MATCH_STATES.PICKING,
MATCH_STATES.PLAYING
])
);
if (Number(activeCount) >= MAX_CONCURRENT_MATCHES) return null;
@@ -170,3 +177,31 @@ async function tryMatchFromQueue() {
return created;
}
async function selectMappoolForRating(avgElo: number) {
// Only use verified mappools
const pools = await db.query.mappool.findMany({
where: (m, { isNotNull }) => isNotNull(m.verifiedAt),
with: { slots: true }
});
if (pools.length === 0) return null;
// Map ELO range (0-3500) to star rating range (2-8)
const targetStars = 2 + avgElo / 700;
let bestPool = pools[0];
for (const pool of pools) {
const avgSR = getAverageMappoolSR(pool);
const diff = Math.abs(avgSR - targetStars);
if (diff < Math.abs(getAverageMappoolSR(bestPool) - targetStars)) {
bestPool = pool;
}
}
return bestPool;
}
function getAverageMappoolSR(pool): number {
return pool.slots.reduce((acc, slot) => acc + slot.starRating!, 0) / pool.slots.length;
}

View File

@@ -6,7 +6,9 @@ import { getUser } from '$lib/server/osu/api';
// ── Initial Rating ──────────────────────────────────────────────────────
export async function calculateInitialElo(userId: string): Promise<{ elo: number; osuRank: number | null }> {
export async function calculateInitialElo(
userId: string
): Promise<{ elo: number; osuRank: number | null }> {
const osuAccount = await db.query.account.findFirst({
where: and(eq(account.userId, userId), eq(account.providerId, 'osu'))
});

View File

@@ -58,10 +58,5 @@ export async function markActionedByReference(referenceId: string, userId: strin
await db
.update(notification)
.set({ read: true, actionedAt: new Date() })
.where(
and(
eq(notification.referenceId, referenceId),
eq(notification.userId, userId)
)
);
.where(and(eq(notification.referenceId, referenceId), eq(notification.userId, userId)));
}

View File

@@ -124,11 +124,7 @@ export async function getUserRole(userId: string): Promise<UserRole> {
/**
* Auto-promote root admin on login if they're still 'player'.
*/
export async function ensureRootAdminRole(userRecord: {
id: string;
email: string;
role: string;
}) {
export async function ensureRootAdminRole(userRecord: { id: string; email: string; role: string }) {
if (isRootAdmin(userRecord.email) && userRecord.role !== 'admin') {
await db.update(user).set({ role: 'admin' }).where(eq(user.id, userRecord.id));
return 'admin';

View File

@@ -3,7 +3,13 @@ import { env } from '$env/dynamic/private';
type S3ClientType = import('bun').S3Client;
function isConfigured(): boolean {
return !!(env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_ACCOUNT_ID && env.R2_BUCKET && env.R2_PUBLIC_URL);
return !!(
env.R2_ACCESS_KEY_ID &&
env.R2_SECRET_ACCESS_KEY &&
env.R2_ACCOUNT_ID &&
env.R2_BUCKET &&
env.R2_PUBLIC_URL
);
}
let client: S3ClientType | null = null;

View File

@@ -67,11 +67,4 @@ function stopPolling() {
}
}
export {
queue,
fetchQueueStatus,
joinQueue,
leaveQueue,
startPolling,
stopPolling
};
export { queue, fetchQueueStatus, joinQueue, leaveQueue, startPolling, stopPolling };

View File

@@ -49,11 +49,7 @@
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="lg:flex-1"></div>
@@ -79,7 +75,7 @@
</div>
<a
href="/matches/{activeMatch.id}"
class="rounded-md border border-yellow-500/40 px-3 py-1 text-xs font-600 text-yellow-300 transition-colors hover:bg-yellow-500/20"
class="font-600 rounded-md border border-yellow-500/40 px-3 py-1 text-xs text-yellow-300 transition-colors hover:bg-yellow-500/20"
>
Go to match &rarr;
</a>

View File

@@ -17,8 +17,15 @@ export const load: PageServerLoad = async ({ locals }) => {
}
// ── LOGGED IN → dashboard data ──
const [recentMatches, liveMatches, [matchCount], [teamCount], [poolCount], [finishedCount], queueStatus] =
await Promise.all([
const [
recentMatches,
liveMatches,
[matchCount],
[teamCount],
[poolCount],
[finishedCount],
queueStatus
] = await Promise.all([
db.query.match.findMany({
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
orderBy: desc(match.createdAt),

View File

@@ -1,10 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import {
queue,
joinQueue,
leaveQueue
} from '$lib/stores/queue.svelte';
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
let { data } = $props();
@@ -44,7 +40,10 @@
<title>Vash Esports</title>
<meta name="description" content="very cool and awesome automated osu! matchmaking platform" />
<meta property="og:title" content="Vash Esports" />
<meta property="og:description" content="very cool and awesome automated osu! matchmaking platform" />
<meta
property="og:description"
content="very cool and awesome automated osu! matchmaking platform"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content={page.url.href} />
<meta property="og:image" content="https://esports.vash.software/logo.png" />
@@ -56,14 +55,20 @@
{#if !data.user}
<div class="flex min-h-[70vh] flex-col items-center justify-center text-center">
<img src="/logo.png" alt="Vash Esports" class="mb-6 h-16 w-16" />
<h1 class="text-4xl font-800 tracking-tight">
Welcome to <span class="text-accent font-semibold">Vash Esports</span>
<h1 class="font-800 text-4xl tracking-tight">
Welcome to <span class="font-semibold text-accent">Vash Esports</span>
</h1>
<p class="mt-3 max-w-md text-text-secondary">
a pretty cool automated osu! matchmaking platform.
</p>
<p class="mt-3 max-w-md text-text-secondary">
Please join <a href="https://discord.gg/n3mZgWk" target="_blank" rel="noopener" class="text-text-primary underline underline-offset-2 hover:text-accent transition-colors">discord.gg/n3mZgWk</a> and give any feedback :)
Please join <a
href="https://discord.gg/n3mZgWk"
target="_blank"
rel="noopener"
class="text-text-primary underline underline-offset-2 transition-colors hover:text-accent"
>discord.gg/n3mZgWk</a
> and give any feedback :)
</p>
<a
href="/login"
@@ -77,31 +82,39 @@
{:else}
<div class="mx-auto max-w-5xl">
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h1 class="text-3xl mb-4 font-semibold text-center font-800 tracking-tight">Vash Esports</h1>
<p class="mt-2 text-sm text-text-secondary leading-relaxed">
Press <span class="font-600 text-text-primary">'Find Match'</span> to join the queue to play a fully automated osu! match with other players in an ELO-appropriate map pool.
<h1 class="font-800 mb-4 text-center text-3xl font-semibold tracking-tight">Vash Esports</h1>
<p class="mt-2 text-sm leading-relaxed text-text-secondary">
Press <span class="font-600 text-text-primary">'Find Match'</span> to join the queue to play a
fully automated osu! match with other players in an ELO-appropriate map pool.
</p>
<p class="mt-2 text-sm text-text-secondary leading-relaxed">
I'll be adding a lot more features soon, like arbitrarily large team sizes, fully automated tournaments, automated broadcasting, and more. Please join <a href="https://discord.gg/n3mZgWk" target="_blank" rel="noopener" class="text-text-primary underline underline-offset-2 hover:text-accent transition-colors">discord.gg/n3mZgWk</a> and give any feedback :)
<p class="mt-2 text-sm leading-relaxed text-text-secondary">
I'll be adding a lot more features soon, like arbitrarily large team sizes, fully automated
tournaments, automated broadcasting, and more. Please join <a
href="https://discord.gg/n3mZgWk"
target="_blank"
rel="noopener"
class="text-text-primary underline underline-offset-2 transition-colors hover:text-accent"
>discord.gg/n3mZgWk</a
> and give any feedback :)
</p>
</div>
<!-- Stats -->
<div class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.matches}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.matches}</p>
<p class="mt-1 text-xs text-text-secondary">Total Matches</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-green-400">{data.stats.finished}</p>
<p class="font-800 text-2xl text-green-400 tabular-nums">{data.stats.finished}</p>
<p class="mt-1 text-xs text-text-secondary">Completed</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.teams}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.teams}</p>
<p class="mt-1 text-xs text-text-secondary">Teams</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 p-4">
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.mappools}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.mappools}</p>
<p class="mt-1 text-xs text-text-secondary">Mappools</p>
</div>
</div>
@@ -114,7 +127,7 @@
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
</div>
<h2 class="text-sm font-600">Live Now (max 4 until osu! gives me bot account lol)</h2>
<h2 class="font-600 text-sm">Live Now (max 4 until osu! gives me bot account lol)</h2>
</div>
<div class="mt-3 flex flex-col gap-2">
{#each data.liveMatches as m}
@@ -129,20 +142,20 @@
{#if p1?.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
<span class="font-800 text-xl tabular-nums">{p1?.score ?? 0}</span>
<span class="text-xs text-text-secondary">vs</span>
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
<span class="font-800 text-xl tabular-nums">{p2?.score ?? 0}</span>
</div>
<div class="flex flex-1 items-center justify-end gap-3">
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
{#if p2?.team.avatarUrl}
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
</div>
<span class="flex items-center gap-1.5 text-xs font-500 {sc?.color ?? ''}">
<span class="font-500 flex items-center gap-1.5 text-xs {sc?.color ?? ''}">
{#if sc?.dot}
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
{/if}
@@ -158,7 +171,7 @@
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-600">Ranked Queue</h2>
<h2 class="font-600 text-sm">Ranked Queue</h2>
<p class="mt-0.5 text-xs text-text-secondary">Find a match at your skill level</p>
</div>
{#if queue.status?.inQueue}
@@ -178,7 +191,7 @@
{:else if data.hasActiveMatch}
<a
href="/matches/{data.activeMatchId}"
class="rounded-md border border-accent/40 px-4 py-2 text-sm font-600 text-accent transition-colors hover:bg-accent/10"
class="font-600 rounded-md border border-accent/40 px-4 py-2 text-sm text-accent transition-colors hover:bg-accent/10"
>
View Active Match →
</a>
@@ -186,7 +199,7 @@
<button
onclick={joinQueue}
disabled={queue.loading}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{queue.loading ? 'Joining...' : 'Find Match'}
</button>
@@ -197,17 +210,31 @@
<div class="mt-8 grid gap-4 lg:grid-cols-3">
<!-- Quick Actions -->
<div class="lg:col-span-1">
<h2 class="text-sm font-600">Quick Actions</h2>
<h2 class="font-600 text-sm">Quick Actions</h2>
<div class="mt-3 flex flex-col gap-2">
<a
href="/matches"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 17.5 3 6V3h3l11.5 11.5"/><path d="m13 19 3.5-3.5"/><path d="m16.5 22 5-5"/><path d="M10 5.5 6 2H3v3l4 4"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
</span>
<div>
<p class="text-sm font-600">Matches</p>
<p class="font-600 text-sm">Matches</p>
<p class="text-xs text-text-secondary">View all matches</p>
</div>
</a>
@@ -215,11 +242,27 @@
href="/mappools"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500/10 text-purple-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="18" r="3"/><path d="M11 18V8l9-1v10"/><circle cx="20" cy="17" r="3"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500/10 text-purple-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="8" cy="18" r="3" /><path d="M11 18V8l9-1v10" /><circle
cx="20"
cy="17"
r="3"
/></svg
>
</span>
<div>
<p class="text-sm font-600">Mappools</p>
<p class="font-600 text-sm">Mappools</p>
<p class="text-xs text-text-secondary">Build a pool</p>
</div>
</a>
@@ -227,11 +270,27 @@
href="/teams"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg
>
</span>
<div>
<p class="text-sm font-600">Teams</p>
<p class="font-600 text-sm">Teams</p>
<p class="text-xs text-text-secondary">Manage rosters</p>
</div>
</a>
@@ -239,11 +298,28 @@
href="/leaderboard"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500/10 text-yellow-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500/10 text-yellow-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="18" y1="20" x2="18" y2="10" /><line
x1="12"
y1="20"
x2="12"
y2="4"
/><line x1="6" y1="20" x2="6" y2="14" /></svg
>
</span>
<div>
<p class="text-sm font-600">Leaderboard</p>
<p class="font-600 text-sm">Leaderboard</p>
<p class="text-xs text-text-secondary">Rankings & stats</p>
</div>
</a>
@@ -252,7 +328,7 @@
<!-- Recent Activity -->
<div class="lg:col-span-2">
<h2 class="text-sm font-600">Recent Matches</h2>
<h2 class="font-600 text-sm">Recent Matches</h2>
<div class="mt-3 flex flex-col gap-2">
{#each data.recentMatches as m}
{@const p1 = m.participants[0]}
@@ -265,27 +341,38 @@
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
{#if m.state === 'FINISHED'}
<span
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
class="font-700 text-xs tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
? 'text-green-400'
: 'text-text-secondary'}">{p1?.score ?? 0}</span
>
<span class="text-xs text-text-secondary">-</span>
<span
class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
class="font-700 text-xs tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
? 'text-green-400'
: 'text-text-secondary'}">{p2?.score ?? 0}</span
>
{:else}
<span class="text-xs text-text-secondary">vs</span>
{/if}
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
</div>
<p class="mt-0.5 text-xs text-text-secondary">{m.name ? `${m.name} · ` : ''}BO{config.bestOf} &middot; {timeAgo(m.finishedAt ?? m.createdAt)}</p>
<p class="mt-0.5 text-xs text-text-secondary">
{m.name ? `${m.name} · ` : ''}BO{config.bestOf} &middot; {timeAgo(
m.finishedAt ?? m.createdAt
)}
</p>
{#if m.mappool}
<p class="text-xs text-text-secondary"><a href='/mappools/{m.mappool.id}' onclick={(e) => e.stopPropagation()} class='hover:text-accent hover:underline'>{m.mappool.name}</a>{#if avgSR(m.mappool)} ({avgSR(m.mappool)}★){/if}</p>
<p class="text-xs text-text-secondary">
<a
href="/mappools/{m.mappool.id}"
onclick={(e) => e.stopPropagation()}
class="hover:text-accent hover:underline">{m.mappool.name}</a
>{#if avgSR(m.mappool)}
({avgSR(m.mappool)}★){/if}
</p>
{/if}
</div>
@@ -299,8 +386,7 @@
{:else}
<div class="rounded-lg border border-dashed border-border py-8 text-center">
<p class="text-sm text-text-secondary">
No matches yet. <a href="/matches" class="text-accent hover:underline"
>Create one</a
No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a
>
</p>
</div>

View File

@@ -102,13 +102,19 @@ export const actions: Actions = {
await db.delete(matchQueue);
return { success: true, message: `Cancelled ${cancelled.length} match${cancelled.length !== 1 ? 'es' : ''} and cleared queue` };
return {
success: true,
message: `Cancelled ${cancelled.length} match${cancelled.length !== 1 ? 'es' : ''} and cleared queue`
};
},
clearQueue: async ({ locals }) => {
requireRole(locals, 'admin');
const deleted = await db.delete(matchQueue).returning({ id: matchQueue.id });
return { success: true, message: `Removed ${deleted.length} queue entr${deleted.length !== 1 ? 'ies' : 'y'}` };
return {
success: true,
message: `Removed ${deleted.length} queue entr${deleted.length !== 1 ? 'ies' : 'y'}`
};
},
expireInvites: async ({ locals }) => {
@@ -118,13 +124,19 @@ export const actions: Actions = {
.set({ status: 'expired' })
.where(eq(matchInvite.status, 'pending'))
.returning({ id: matchInvite.id });
return { success: true, message: `Expired ${expired.length} pending invite${expired.length !== 1 ? 's' : ''}` };
return {
success: true,
message: `Expired ${expired.length} pending invite${expired.length !== 1 ? 's' : ''}`
};
},
clearNotifications: async ({ locals }) => {
requireRole(locals, 'admin');
const deleted = await db.delete(notification).returning({ id: notification.id });
return { success: true, message: `Cleared ${deleted.length} notification${deleted.length !== 1 ? 's' : ''}` };
return {
success: true,
message: `Cleared ${deleted.length} notification${deleted.length !== 1 ? 's' : ''}`
};
},
resetRatings: async ({ locals }) => {
@@ -161,7 +173,14 @@ export const actions: Actions = {
if (existing) {
await db
.update(playerRating)
.set({ elo, initialElo: elo, osuRankAtSeed: rank, wins: 0, losses: 0, updatedAt: new Date() })
.set({
elo,
initialElo: elo,
osuRankAtSeed: rank,
wins: 0,
losses: 0,
updatedAt: new Date()
})
.where(eq(playerRating.userId, u.id));
} else {
await db.insert(playerRating).values({
@@ -178,7 +197,10 @@ export const actions: Actions = {
updated++;
}
return { success: true, message: `Re-seeded ${updated} rating${updated !== 1 ? 's' : ''} from osu! ranks` };
return {
success: true,
message: `Re-seeded ${updated} rating${updated !== 1 ? 's' : ''} from osu! ranks`
};
},
clearMatchHistory: async ({ locals }) => {

View File

@@ -17,19 +17,19 @@
// Non-root admins can only assign player/referee
const availableRoles = $derived(
data.actorIsRootAdmin
? ['player', 'referee', 'admin']
: ['player', 'referee']
data.actorIsRootAdmin ? ['player', 'referee', 'admin'] : ['player', 'referee']
);
</script>
<div class="mx-auto max-w-4xl">
<div>
<h1 class="text-2xl font-700 tracking-tight">Admin Panel</h1>
<h1 class="font-700 text-2xl tracking-tight">Admin Panel</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.users.length} registered user{data.users.length !== 1 ? 's' : ''}
{#if data.actorIsRootAdmin}
<span class="ml-2 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] font-700 text-red-400">ROOT ADMIN</span>
<span class="font-700 ml-2 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
>ROOT ADMIN</span
>
{/if}
</p>
</div>
@@ -44,11 +44,11 @@
<!-- Users Table -->
<div class="mt-6 rounded-lg border border-border bg-surface-800">
<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3">
<span class="text-xs font-600 text-text-secondary">Avatar</span>
<span class="text-xs font-600 text-text-secondary">User</span>
<span class="text-xs font-600 text-text-secondary">ELO</span>
<span class="text-xs font-600 text-text-secondary">W/L</span>
<span class="text-xs font-600 text-text-secondary">Role</span>
<span class="font-600 text-xs text-text-secondary">Avatar</span>
<span class="font-600 text-xs text-text-secondary">User</span>
<span class="font-600 text-xs text-text-secondary">ELO</span>
<span class="font-600 text-xs text-text-secondary">W/L</span>
<span class="font-600 text-xs text-text-secondary">Role</span>
</div>
{#each data.users as u}
@@ -63,7 +63,7 @@
<img src={u.image} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{u.name.charAt(0).toUpperCase()}
</div>
@@ -72,19 +72,21 @@
<!-- Name -->
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-500">{u.name}</p>
<p class="font-500 truncate text-sm">{u.name}</p>
{#if u.isRootAdmin}
<span class="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] font-700 text-red-400">ROOT</span>
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
>ROOT</span
>
{/if}
</div>
<p class="truncate text-xs text-text-secondary">{u.email}</p>
</div>
<!-- ELO -->
<span class="text-sm font-600 tabular-nums text-text-primary">{u.elo}</span>
<span class="font-600 text-sm text-text-primary tabular-nums">{u.elo}</span>
<!-- W/L -->
<span class="text-xs tabular-nums text-text-secondary">
<span class="text-xs text-text-secondary tabular-nums">
<span class="text-green-400">{u.wins}W</span> /
<span class="text-red-400">{u.losses}L</span>
</span>
@@ -112,7 +114,7 @@
name="role"
value={u.role}
onchange={(e) => e.currentTarget.form?.requestSubmit()}
class="rounded-md border px-2 py-1 text-xs font-500 {badge.color} cursor-pointer bg-transparent focus:outline-none"
class="font-500 rounded-md border px-2 py-1 text-xs {badge.color} cursor-pointer bg-transparent focus:outline-none"
>
{#each availableRoles as r}
<option value={r} class="bg-surface-800 text-text-primary">
@@ -122,7 +124,7 @@
</select>
</form>
{:else}
<span class="rounded-md border px-2 py-1 text-xs font-500 {badge.color}">
<span class="font-500 rounded-md border px-2 py-1 text-xs {badge.color}">
{badge.label}
</span>
{/if}
@@ -132,25 +134,24 @@
<!-- Admin Tools -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<h2 class="text-sm font-600">Tools</h2>
<h2 class="font-600 text-sm">Tools</h2>
<!-- Stats -->
<div class="mt-3 flex gap-4 text-xs text-text-secondary">
<span>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span>
<span
>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span
>
<span>{data.stats.queueSize} in queue</span>
<span>{data.stats.pendingInvites} pending invite{data.stats.pendingInvites !== 1 ? 's' : ''}</span>
<span
>{data.stats.pendingInvites} pending invite{data.stats.pendingInvites !== 1
? 's'
: ''}</span
>
</div>
<!-- Actions -->
<div class="mt-4 flex flex-wrap gap-2">
{#each [
{ action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' },
{ action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' },
{ action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' },
{ action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' },
{ action: 'resetRatings', label: 'Re-seed Ratings from osu!', confirm: 'Fetch all osu! ranks and re-seed ELO ratings? W/L records will be reset. Unranked players get 10M rank. This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' },
{ action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' },
] as tool}
{#each [{ action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' }, { action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'resetRatings', label: 'Re-seed Ratings from osu!', confirm: 'Fetch all osu! ranks and re-seed ELO ratings? W/L records will be reset. Unranked players get 10M rank. This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }, { action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }] as tool}
<form
method="post"
action="?/{tool.action}"
@@ -169,9 +170,12 @@
>
<button
type="submit"
onclick={(e) => { if (!confirm(tool.confirm)) e.preventDefault(); }}
class="rounded-md border px-3 py-1.5 text-xs font-500 transition-colors {tool.color}"
>{tool.label}</button>
onclick={(e) => {
if (!confirm(tool.confirm)) e.preventDefault();
}}
class="font-500 rounded-md border px-3 py-1.5 text-xs transition-colors {tool.color}"
>{tool.label}</button
>
</form>
{/each}
</div>
@@ -179,23 +183,24 @@
<!-- Permission Info -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<h2 class="text-sm font-600">Permission Levels</h2>
<h2 class="font-600 text-sm">Permission Levels</h2>
<div class="mt-3 flex flex-col gap-2 text-xs text-text-secondary">
<div class="flex items-center gap-2">
<span class="rounded bg-surface-600 px-1.5 py-0.5 font-600 text-text-secondary">Player</span>
<span class="font-600 rounded bg-surface-600 px-1.5 py-0.5 text-text-secondary">Player</span
>
Create account, join queue, create/manage own mappools and teams
</div>
<div class="flex items-center gap-2">
<span class="rounded bg-yellow-500/20 px-1.5 py-0.5 font-600 text-yellow-400">Referee</span>
<span class="font-600 rounded bg-yellow-500/20 px-1.5 py-0.5 text-yellow-400">Referee</span>
All player permissions + manage any match, force start games
</div>
<div class="flex items-center gap-2">
<span class="rounded bg-red-500/20 px-1.5 py-0.5 font-600 text-red-400">Admin</span>
<span class="font-600 rounded bg-red-500/20 px-1.5 py-0.5 text-red-400">Admin</span>
Full access + manage all mappools/teams + promote players to referee
</div>
{#if data.actorIsRootAdmin}
<div class="flex items-center gap-2">
<span class="rounded bg-red-500/20 px-1.5 py-0.5 font-700 text-red-400">Root Admin</span>
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-red-400">Root Admin</span>
Can promote/demote admins. Set via ROOT_ADMIN_EMAIL env var. Cannot be demoted.
</div>
{/if}

View File

@@ -33,10 +33,7 @@ export const load: PageServerLoad = async () => {
elo: r.elo,
wins: r.wins,
losses: r.losses,
winRate:
r.wins + r.losses > 0
? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1)
: '—'
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
};
})
);
@@ -49,10 +46,7 @@ export const load: PageServerLoad = async () => {
wins: sql<number>`count(*)`.as('wins')
})
.from(match)
.where(and(
sql`${match.winnerId} IS NOT NULL`,
eq(match.state, 'FINISHED')
))
.where(and(sql`${match.winnerId} IS NOT NULL`, eq(match.state, 'FINISHED')))
.groupBy(match.winnerId)
.orderBy(sql`count(*) DESC`)
.limit(15);
@@ -92,10 +86,7 @@ export const load: PageServerLoad = async () => {
const recentHighScores = await db
.select()
.from(matchGameScore)
.orderBy(
sql`${matchGameScore.pp} DESC NULLS LAST`,
desc(matchGameScore.score)
)
.orderBy(sql`${matchGameScore.pp} DESC NULLS LAST`, desc(matchGameScore.score))
.limit(15)
.then((rows) =>
Promise.all(

View File

@@ -45,7 +45,7 @@
<div class="mx-auto max-w-4xl">
<div>
<h1 class="text-2xl font-700 tracking-tight">Leaderboard</h1>
<h1 class="font-700 text-2xl tracking-tight">Leaderboard</h1>
<p class="mt-1 text-sm text-text-secondary">Rankings and top performances</p>
</div>
@@ -54,7 +54,7 @@
{#each tabs as tab}
<button
onclick={() => (activeTab = tab.id)}
class="flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-600 transition-colors {activeTab ===
class="font-600 flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm transition-colors {activeTab ===
tab.id
? 'bg-accent text-surface-900'
: 'text-text-secondary hover:text-text-primary'}"
@@ -75,7 +75,7 @@
{:else}
<table class="w-full">
<thead>
<tr class="border-b border-border text-left text-xs font-600 text-text-secondary">
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
<th class="w-10 py-3 pl-4 text-center">#</th>
<th class="w-10 py-3"></th>
<th class="py-3">Player</th>
@@ -87,25 +87,43 @@
<tbody>
{#each data.topPlayers as p}
{@const medal = rankBadge(p.rank)}
<tr class="border-b border-border/50 last:border-b-0 {p.rank <= 3 ? 'bg-accent/[0.02]' : ''}">
<td class="py-3 pl-4 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
<tr
class="border-b border-border/50 last:border-b-0 {p.rank <= 3
? 'bg-accent/[0.02]'
: ''}"
>
<td
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
'text-text-secondary'}"
>
{medal ? medal.emoji : p.rank}
</td>
<td class="py-3">
{#if p.image}
<img src={p.image} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
<div
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{p.name.charAt(0).toUpperCase()}
</div>
{/if}
</td>
<td class="py-3 text-sm font-600">{p.name}</td>
<td class="py-3 pr-4 text-right text-sm font-700 tabular-nums {eloColor(p.elo)}">{p.elo}</td>
<td class="py-3 pr-4 text-right text-xs tabular-nums text-text-secondary">
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400">{p.losses}</span>
<td class="font-600 py-3 text-sm">{p.name}</td>
<td class="font-700 py-3 pr-4 text-right text-sm tabular-nums {eloColor(p.elo)}"
>{p.elo}</td
>
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums">
<span class="text-green-400">{p.wins}</span>/<span class="text-red-400"
>{p.losses}</span
>
</td>
<td class="py-3 pr-4 text-right text-xs font-600 tabular-nums {p.winRate !== '—' && parseFloat(p.winRate) >= 50 ? 'text-green-400' : 'text-text-secondary'}">
<td
class="font-600 py-3 pr-4 text-right text-xs tabular-nums {p.winRate !== '—' &&
parseFloat(p.winRate) >= 50
? 'text-green-400'
: 'text-text-secondary'}"
>
{p.winRate}{p.winRate !== '—' ? '%' : ''}
</td>
</tr>
@@ -126,7 +144,7 @@
{:else}
<table class="w-full">
<thead>
<tr class="border-b border-border text-left text-xs font-600 text-text-secondary">
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
<th class="w-10 py-3 pl-4 text-center">#</th>
<th class="w-10 py-3"></th>
<th class="py-3">Team</th>
@@ -137,28 +155,43 @@
<tbody>
{#each data.topTeams as t}
{@const medal = rankBadge(t.rank)}
<tr class="border-b border-border/50 last:border-b-0 {t.rank <= 3 ? 'bg-accent/[0.02]' : ''}">
<td class="py-3 pl-4 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
<tr
class="border-b border-border/50 last:border-b-0 {t.rank <= 3
? 'bg-accent/[0.02]'
: ''}"
>
<td
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
'text-text-secondary'}"
>
{medal ? medal.emoji : t.rank}
</td>
<td class="py-3">
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
<div
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
</td>
<td class="py-3">
<span class="text-sm font-600">{t.name}</span>
<span class="font-600 text-sm">{t.name}</span>
{#if t.isPersonal}
<span class="ml-1 text-[10px] text-text-secondary">(solo)</span>
{/if}
<p class="text-xs text-text-secondary">{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}</p>
<p class="text-xs text-text-secondary">
{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}
</p>
</td>
<td class="py-3 pr-4 text-right text-sm font-700 tabular-nums text-green-400">{t.wins}</td>
<td class="py-3 pr-4 text-right text-xs tabular-nums text-text-secondary">{t.totalMatches}</td>
<td class="font-700 py-3 pr-4 text-right text-sm text-green-400 tabular-nums"
>{t.wins}</td
>
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums"
>{t.totalMatches}</td
>
</tr>
{/each}
</tbody>
@@ -175,12 +208,14 @@
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
</div>
{:else}
<div class="px-4 py-2.5 border-b border-border">
<p class="text-xs text-text-secondary">Ranked by pp (performance points) when available, then score</p>
<div class="border-b border-border px-4 py-2.5">
<p class="text-xs text-text-secondary">
Ranked by pp (performance points) when available, then score
</p>
</div>
<table class="w-full">
<thead>
<tr class="border-b border-border text-left text-xs font-600 text-text-secondary">
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
<th class="w-10 py-3 pl-4 text-center">#</th>
<th class="w-10 py-3"></th>
<th class="py-3">Player</th>
@@ -193,35 +228,51 @@
<tbody>
{#each data.highScores as s}
{@const medal = rankBadge(s.rank)}
<tr class="border-b border-border/50 last:border-b-0 {s.rank <= 3 ? 'bg-accent/[0.02]' : ''}">
<td class="py-3 pl-4 text-center text-sm font-700 tabular-nums {medal?.color ?? 'text-text-secondary'}">
<tr
class="border-b border-border/50 last:border-b-0 {s.rank <= 3
? 'bg-accent/[0.02]'
: ''}"
>
<td
class="font-700 py-3 pl-4 text-center text-sm tabular-nums {medal?.color ??
'text-text-secondary'}"
>
{medal ? medal.emoji : s.rank}
</td>
<td class="py-3">
{#if s.playerImage}
<img src={s.playerImage} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
<div
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{s.playerName.charAt(0).toUpperCase()}
</div>
{/if}
</td>
<td class="py-3">
<span class="text-sm font-600">{s.playerName}</span>
<span class="font-600 text-sm">{s.playerName}</span>
<p class="truncate text-xs text-text-secondary">
<a href="/matches/{s.matchId}" class="hover:text-accent">{s.matchName}</a>
</p>
</td>
<td class="py-3 pr-4 text-right text-xs font-600 {catColors[s.mapCategory] ?? 'text-text-secondary'}">
<td
class="font-600 py-3 pr-4 text-right text-xs {catColors[s.mapCategory] ??
'text-text-secondary'}"
>
{s.mapCategory}{s.mapOrder}
</td>
<td class="py-3 pr-4 text-right text-sm font-700 tabular-nums {s.pp != null ? 'text-accent' : 'text-text-secondary/40'}">
<td
class="font-700 py-3 pr-4 text-right text-sm tabular-nums {s.pp != null
? 'text-accent'
: 'text-text-secondary/40'}"
>
{s.pp != null ? `${Math.round(s.pp)}pp` : '—'}
</td>
<td class="py-3 pr-4 text-right text-sm font-700 tabular-nums text-text-primary">
<td class="font-700 py-3 pr-4 text-right text-sm text-text-primary tabular-nums">
{s.score.toLocaleString()}
</td>
<td class="py-3 pr-4 text-right text-xs tabular-nums text-text-secondary">
<td class="py-3 pr-4 text-right text-xs text-text-secondary tabular-nums">
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
</td>
</tr>
@@ -234,13 +285,12 @@
<!-- ELO Explanation -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<h2 class="text-sm font-600">How ELO Works</h2>
<p class="mt-2 text-xs text-text-secondary leading-relaxed">
Your initial rating is seeded from your osu! global rank. Winning gains ELO and
losing drops it, but the amount depends on your opponent's rating — beating a
stronger player earns more points. New players' ratings shift faster to help
find your true skill level quickly. Queue matchmaking pairs players with similar
ELO for fair matches.
<h2 class="font-600 text-sm">How ELO Works</h2>
<p class="mt-2 text-xs leading-relaxed text-text-secondary">
Your initial rating is seeded from your osu! global rank. Winning gains ELO and losing drops
it, but the amount depends on your opponent's rating — beating a stronger player earns more
points. New players' ratings shift faster to help find your true skill level quickly. Queue
matchmaking pairs players with similar ELO for fair matches.
</p>
</div>
</div>

View File

@@ -8,7 +8,7 @@ export const load: PageServerLoad = async ({ locals }) => {
const mappools = await db.query.mappool.findMany({ with: { slots: true } });
const avgSR = (m: typeof mappools[0]) =>
const avgSR = (m: (typeof mappools)[0]) =>
m.slots.length ? m.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / m.slots.length : 0;
mappools.sort((a, b) => avgSR(b) - avgSR(a));

View File

@@ -9,7 +9,10 @@
<title>Mappools — Vash Esports</title>
<meta name="description" content="Browse and manage osu! tournament mappools on Vash Esports." />
<meta property="og:title" content="Mappools — Vash Esports" />
<meta property="og:description" content="Browse and manage osu! tournament mappools on Vash Esports." />
<meta
property="og:description"
content="Browse and manage osu! tournament mappools on Vash Esports."
/>
<meta property="og:site_name" content="Vash Esports" />
<meta name="twitter:card" content="summary" />
</svelte:head>
@@ -17,12 +20,12 @@
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Mappools</h1>
<h1 class="font-700 text-2xl tracking-tight">Mappools</h1>
<p class="mt-1 text-sm text-text-secondary">Create and manage your beatmap pools</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Pool'}
</button>
@@ -44,7 +47,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
@@ -54,16 +57,22 @@
<div class="mt-6 flex flex-col gap-3">
{#each data.mappools as pool}
{@const rated = pool.slots.filter((s: any) => s.starRating != null)}
{@const avgSr = rated.length > 0 ? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2) : null}
{@const avgSr =
rated.length > 0
? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2)
: null}
<a
href="/mappools/{pool.id}"
class="group flex items-center justify-between rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<div>
<div class="flex items-center gap-2">
<h2 class="text-sm font-600">{pool.name}</h2>
<h2 class="font-600 text-sm">{pool.name}</h2>
{#if pool.verifiedAt}
<span class="rounded border border-green-500/30 bg-green-500/10 px-1.5 py-0.5 text-[10px] font-600 text-green-400">✓ Verified</span>
<span
class="font-600 rounded border border-green-500/30 bg-green-500/10 px-1.5 py-0.5 text-[10px] text-green-400"
>✓ Verified</span
>
{/if}
</div>
<p class="mt-1 text-xs text-text-secondary">

View File

@@ -190,10 +190,7 @@ export const actions: Actions = {
requireAuth(locals);
if (locals.user!.role !== 'admin') error(403, 'Admins only');
await db
.update(mappool)
.set({ verifiedAt: new Date() })
.where(eq(mappool.id, params.id));
await db.update(mappool).set({ verifiedAt: new Date() }).where(eq(mappool.id, params.id));
return { success: true };
},
@@ -202,10 +199,7 @@ export const actions: Actions = {
requireAuth(locals);
if (locals.user!.role !== 'admin') error(403, 'Admins only');
await db
.update(mappool)
.set({ verifiedAt: null })
.where(eq(mappool.id, params.id));
await db.update(mappool).set({ verifiedAt: null }).where(eq(mappool.id, params.id));
return { success: true };
},
@@ -234,10 +228,7 @@ export const actions: Actions = {
// Fetch target category slots (excluding the moved slot)
const targetSlots = await db.query.mappoolSlot.findMany({
where: and(
eq(mappoolSlot.mappoolId, params.id),
eq(mappoolSlot.category, targetCategory)
),
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, targetCategory)),
orderBy: [asc(mappoolSlot.orderInCategory)]
});
const filteredTarget = targetSlots.filter((s) => s.id !== slotId);
@@ -267,10 +258,7 @@ export const actions: Actions = {
// If cross-category move, re-number the source category too
if (!isSameCategory) {
const sourceSlots = await db.query.mappoolSlot.findMany({
where: and(
eq(mappoolSlot.mappoolId, params.id),
eq(mappoolSlot.category, sourceCategory)
),
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, sourceCategory)),
orderBy: [asc(mappoolSlot.orderInCategory)]
});
for (let i = 0; i < sourceSlots.length; i++) {
@@ -292,7 +280,10 @@ export const actions: Actions = {
const raw = form.get('data')?.toString()?.trim();
if (!raw) return { error: 'No data provided' };
const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean);
const lines = raw
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
const validMods = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
const errors: string[] = [];
let successCount = 0;

View File

@@ -152,13 +152,6 @@
};
</script>
<style>
:global([draggable='true']) {
-webkit-user-select: none;
user-select: none;
}
</style>
<svelte:head>
<title>{data.pool.name} — Mappool | Vash Esports</title>
<meta
@@ -200,11 +193,11 @@
type="text"
name="name"
value={editName}
class="rounded-md border border-accent bg-surface-700 px-3 py-1 text-xl font-700 text-text-primary focus:outline-none"
class="font-700 rounded-md border border-accent bg-surface-700 px-3 py-1 text-xl text-text-primary focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-3 py-1 text-xs text-surface-900 hover:bg-accent-hover"
>
Save
</button>
@@ -220,10 +213,10 @@
</form>
{:else}
<div class="flex items-center gap-2">
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
<h1 class="font-700 text-2xl tracking-tight">{data.pool.name}</h1>
{#if data.pool.verifiedAt}
<span
class="flex items-center gap-1 rounded border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs font-600 text-green-400"
class="font-600 flex items-center gap-1 rounded border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs text-green-400"
>
&#10003; Verified
</span>
@@ -305,7 +298,7 @@
}}
class="mt-6 rounded-lg border border-border bg-surface-800 p-4"
>
<h2 class="text-sm font-600">Add Map</h2>
<h2 class="font-600 text-sm">Add Map</h2>
<div class="mt-3 flex gap-3">
<select
name="category"
@@ -328,7 +321,7 @@
<button
type="submit"
disabled={searching || !beatmapInput.trim()}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{searching ? 'Adding...' : 'Add'}
</button>
@@ -345,8 +338,10 @@
<div class="mt-4 rounded-lg border border-border bg-surface-800">
<button
type="button"
onclick={() => { bulkOpen = !bulkOpen; }}
class="flex w-full items-center justify-between px-4 py-3 text-sm font-600 text-text-secondary hover:text-text-primary transition-colors"
onclick={() => {
bulkOpen = !bulkOpen;
}}
class="font-600 flex w-full items-center justify-between px-4 py-3 text-sm text-text-secondary transition-colors hover:text-text-primary"
>
Bulk Import
<span class="text-xs">{bulkOpen ? '▲' : '▼'}</span>
@@ -371,7 +366,10 @@
}
}
} else if (result.type === 'failure') {
bulkResult = { successCount: 0, errors: [(result.data as any)?.error ?? 'Import failed'] };
bulkResult = {
successCount: 0,
errors: [(result.data as any)?.error ?? 'Import failed']
};
}
};
}}
@@ -380,7 +378,7 @@
<textarea
name="data"
bind:value={bulkData}
placeholder={"NM1\t181589\nNM2\t2603758\nHR1\t948389\nTB\t457061"}
placeholder={'NM1\t181589\nNM2\t2603758\nHR1\t948389\nTB\t457061'}
rows="6"
class="w-full rounded-md border border-border bg-surface-700 px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
></textarea>
@@ -388,7 +386,7 @@
<button
type="submit"
disabled={bulkImporting || !bulkData.trim()}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{bulkImporting ? 'Importing...' : 'Import'}
</button>
@@ -400,7 +398,9 @@
{#if bulkResult}
<div class="mt-3 rounded-md border border-border bg-surface-700 p-3 text-sm">
{#if bulkResult.successCount > 0}
<p class="text-green-400">{bulkResult.successCount} map{bulkResult.successCount !== 1 ? 's' : ''} imported successfully.</p>
<p class="text-green-400">
{bulkResult.successCount} map{bulkResult.successCount !== 1 ? 's' : ''} imported successfully.
</p>
{/if}
{#if bulkResult.errors.length > 0}
<div class="mt-1 space-y-1">
@@ -431,9 +431,8 @@
ondrop={handleDrop}
>
<span
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[
category
] ?? 'bg-surface-600 text-text-secondary border-border'}"
class="font-600 rounded border px-2 py-0.5 text-xs {categoryColors[category] ??
'border-border bg-surface-600 text-text-secondary'}"
>
{category}
</span>
@@ -471,7 +470,9 @@
<div
class="group relative flex items-center gap-3 rounded-lg border bg-surface-800 p-2 transition-colors
{data.canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
{draggedSlot?.id === slot.id ? 'border-accent/50 opacity-40' : 'border-border hover:border-accent/30'}"
{draggedSlot?.id === slot.id
? 'border-accent/50 opacity-40'
: 'border-border hover:border-accent/30'}"
style={dropTarget?.category === category && dropTarget.index === i
? `box-shadow: 0 -3px 0 0 var(--color-accent, #8b5cf6); margin-top: 4px;`
: dropTarget?.category === category &&
@@ -482,16 +483,17 @@
ondragover={(e) => handleDragOver(e, category, i)}
ondrop={handleDrop}
role={data.canEdit ? 'listitem' : undefined}
use:makeDraggable={{ slotId: slot.id, category, index: i, enabled: data.canEdit }}
use:makeDraggable={{
slotId: slot.id,
category,
index: i,
enabled: data.canEdit
}}
>
<!-- Drag grip icon -->
{#if data.canEdit}
<div class="flex flex-col gap-0.5 text-text-secondary/40">
<svg
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="6" r="1.5" />
<circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
@@ -523,25 +525,15 @@
<button
type="button"
onclick={() => window.open(slot.beatmap?.url, '_blank')}
class="truncate text-left text-sm font-500 hover:text-accent"
class="font-500 truncate text-left text-sm hover:text-accent"
>
{slot.beatmap.artist} - {slot.beatmap.title}
</button>
<div
class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary"
>
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
<span>[{slot.beatmap.version}]</span>
<span
>{slot.beatmap.starRating.toFixed(
2
)}&#9733;</span
>
<span>{slot.beatmap.starRating.toFixed(2)}&#9733;</span>
<span>{slot.beatmap.bpm} BPM</span>
<span
>{formatLength(
slot.beatmap.totalLength
)}</span
>
<span>{formatLength(slot.beatmap.totalLength)}</span>
</div>
{:else}
<p class="text-sm text-text-secondary">
@@ -551,21 +543,17 @@
</div>
<!-- Slot label -->
<span class="text-xs font-600 text-text-secondary">
<span class="font-600 text-xs text-text-secondary">
{category}{i + 1}
</span>
<!-- Delete -->
{#if data.canEdit}
<form method="post" action="?/removeSlot" use:enhance>
<input
type="hidden"
name="slotId"
value={slot.id}
/>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
class="rounded p-1 text-text-secondary opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
class="rounded p-1 text-text-secondary opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400"
>
&#10005;
</button>
@@ -590,35 +578,26 @@
await update();
} else if (result.type === 'failure') {
inlineErrors[category] =
(result.data as { error?: string })
?.error ?? 'Failed to add map';
(result.data as { error?: string })?.error ?? 'Failed to add map';
}
};
}}
class="flex items-center gap-2"
>
<input
type="hidden"
name="category"
value={category}
/>
<input type="hidden" name="category" value={category} />
<input
type="text"
name="beatmapId"
bind:value={inlineInputs[category]}
placeholder="Add {category}{slots.length +
1} — paste beatmap ID or URL"
placeholder="Add {category}{slots.length + 1} — paste beatmap ID or URL"
class="flex-1 rounded-md border border-dashed border-border bg-transparent px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
/>
<button
type="submit"
disabled={inlineSearching[category] ||
!inlineInputs[category]?.trim()}
class="rounded-md bg-accent/10 px-3 py-1.5 text-xs font-600 text-accent transition-colors hover:bg-accent/20 disabled:opacity-40"
disabled={inlineSearching[category] || !inlineInputs[category]?.trim()}
class="font-600 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent transition-colors hover:bg-accent/20 disabled:opacity-40"
>
{inlineSearching[category]
? 'Adding...'
: '+ Add'}
{inlineSearching[category] ? 'Adding...' : '+ Add'}
</button>
</form>
{#if inlineErrors[category]}
@@ -640,3 +619,10 @@
{/if}
</div>
</div>
<style>
:global([draggable='true']) {
-webkit-user-select: none;
user-select: none;
}
</style>

View File

@@ -91,7 +91,11 @@ export const actions: Actions = {
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
const teamSize1 = parseInt(form.get('teamSize1')?.toString() ?? '1');
const teamSize2 = parseInt(form.get('teamSize2')?.toString() ?? '1');
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as 'score' | 'score_v2' | 'accuracy' | 'combo';
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as
| 'score'
| 'score_v2'
| 'accuracy'
| 'combo';
const allowEloChange = form.get('allowEloChange') === 'on';
const message = form.get('message')?.toString()?.trim() || '';
const scheduledAtStr = form.get('scheduledAt')?.toString();

View File

@@ -19,7 +19,9 @@
let opponentSearch = $state('');
const personalTeam = $derived((data as any).userTeams?.find((t: any) => t.isPersonal));
const nonPersonalUserTeams = $derived((data as any).userTeams?.filter((t: any) => !t.isPersonal) ?? []);
const nonPersonalUserTeams = $derived(
(data as any).userTeams?.filter((t: any) => !t.isPersonal) ?? []
);
const hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
// Auto-select personal team by default
@@ -130,7 +132,7 @@
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Matches</h1>
<h1 class="font-700 text-2xl tracking-tight">Matches</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
{#if liveMatches.length > 0}
@@ -141,16 +143,22 @@
<div class="flex gap-2">
{#if (data as any).userTeams?.length > 0}
<button
onclick={() => { showInvite = !showInvite; if (showInvite) showCreate = false; }}
class="rounded-md border border-accent/30 px-4 py-2 text-sm font-600 text-accent transition-colors hover:bg-accent/10"
onclick={() => {
showInvite = !showInvite;
if (showInvite) showCreate = false;
}}
class="font-600 rounded-md border border-accent/30 px-4 py-2 text-sm text-accent transition-colors hover:bg-accent/10"
>
{showInvite ? 'Cancel' : 'Challenge'}
</button>
{/if}
{#if data.canCreateMatch}
<button
onclick={() => { showCreate = !showCreate; if (showCreate) showInvite = false; }}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
onclick={() => {
showCreate = !showCreate;
if (showCreate) showInvite = false;
}}
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{showCreate ? 'Cancel' : 'New Match'}
</button>
@@ -178,7 +186,7 @@
}}
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
>
<h2 class="text-sm font-600">Create Match</h2>
<h2 class="font-600 text-sm">Create Match</h2>
{#if createError}
<p class="mt-2 text-sm text-red-400">{createError}</p>
@@ -186,7 +194,7 @@
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label for="name" class="text-xs font-500 text-text-secondary">Match Name</label>
<label for="name" class="font-500 text-xs text-text-secondary">Match Name</label>
<input
type="text"
id="name"
@@ -197,7 +205,7 @@
</div>
<div>
<label for="bestOf" class="text-xs font-500 text-text-secondary">Best Of</label>
<label for="bestOf" class="font-500 text-xs text-text-secondary">Best Of</label>
<select
id="bestOf"
name="bestOf"
@@ -210,7 +218,7 @@
</div>
<div>
<label for="team1" class="text-xs font-500 text-text-secondary">Team 1</label>
<label for="team1" class="font-500 text-xs text-text-secondary">Team 1</label>
<select
id="team1"
name="team1"
@@ -225,7 +233,7 @@
</div>
<div>
<label for="team2" class="text-xs font-500 text-text-secondary">Team 2</label>
<label for="team2" class="font-500 text-xs text-text-secondary">Team 2</label>
<select
id="team2"
name="team2"
@@ -240,7 +248,7 @@
</div>
<div class="col-span-2">
<label for="mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
<label for="mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
<select
id="mappool"
name="mappool"
@@ -258,7 +266,7 @@
<div class="mt-4 flex justify-end">
<button
type="submit"
class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create &amp; Start
</button>
@@ -275,12 +283,18 @@
inviteError = '';
inviteSuccess = false;
return async ({ result, update }) => {
if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
if (
result.type === 'failure' ||
(result.type === 'success' && (result.data as any)?.error)
) {
inviteError = (result.data as any)?.error ?? 'Failed to send invite';
} else if (result.type === 'success' && (result.data as any)?.inviteSuccess) {
inviteSuccess = true;
await invalidateAll();
setTimeout(() => { showInvite = false; inviteSuccess = false; }, 2000);
setTimeout(() => {
showInvite = false;
inviteSuccess = false;
}, 2000);
} else {
await update();
}
@@ -288,7 +302,7 @@
}}
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
>
<h2 class="text-sm font-600">Challenge</h2>
<h2 class="font-600 text-sm">Challenge</h2>
{#if inviteError}
<p class="mt-2 text-sm text-red-400">{inviteError}</p>
@@ -305,8 +319,12 @@
<!-- Your Team (only shown if you have non-personal teams) -->
{#if hasMultipleTeams}
<div>
<label for="inv-myTeam" class="text-xs font-500 text-text-secondary">Your Team</label>
<select id="inv-myTeam" bind:value={selectedCreatorTeamId} class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-myTeam" class="font-500 text-xs text-text-secondary">Your Team</label>
<select
id="inv-myTeam"
bind:value={selectedCreatorTeamId}
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#if personalTeam}
<option value={personalTeam.id}>{teamDisplayName(personalTeam)}</option>
{/if}
@@ -319,7 +337,9 @@
<!-- Opponent (searchable) -->
<div>
<label for="inv-opponent-search" class="text-xs font-500 text-text-secondary">Opponent</label>
<label for="inv-opponent-search" class="font-500 text-xs text-text-secondary"
>Opponent</label
>
<input
type="text"
id="inv-opponent-search"
@@ -329,17 +349,35 @@
autocomplete="off"
/>
{#if selectedInvitedTeam}
<div class="mt-1.5 flex items-center gap-2 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent">
<div
class="mt-1.5 flex items-center gap-2 rounded-md bg-accent/10 px-3 py-1.5 text-xs text-accent"
>
<span class="font-600">{teamDisplayName(selectedInvitedTeam)}</span>
<span class="text-text-secondary">({selectedInvitedTeam.memberCount} player{selectedInvitedTeam.memberCount !== 1 ? 's' : ''})</span>
<button type="button" onclick={() => { selectedInvitedTeamId = ''; opponentSearch = ''; }} class="ml-auto text-text-secondary hover:text-red-400">&times;</button>
<span class="text-text-secondary"
>({selectedInvitedTeam.memberCount} player{selectedInvitedTeam.memberCount !== 1
? 's'
: ''})</span
>
<button
type="button"
onclick={() => {
selectedInvitedTeamId = '';
opponentSearch = '';
}}
class="ml-auto text-text-secondary hover:text-red-400">&times;</button
>
</div>
{:else if opponentSearch}
<div class="mt-1 max-h-40 overflow-y-auto rounded-md border border-border bg-surface-700">
<div
class="mt-1 max-h-40 overflow-y-auto rounded-md border border-border bg-surface-700"
>
{#each filteredOpponentTeams.slice(0, 20) as t}
<button
type="button"
onclick={() => { selectedInvitedTeamId = t.id; opponentSearch = ''; }}
onclick={() => {
selectedInvitedTeamId = t.id;
opponentSearch = '';
}}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-600"
>
{#if t.avatarUrl}
@@ -359,14 +397,22 @@
<!-- Best Of -->
<div>
<span class="text-xs font-500 text-text-secondary">Best Of</span>
<span class="font-500 text-xs text-text-secondary">Best Of</span>
<div class="mt-1.5 flex flex-wrap gap-1.5">
{#each [3, 5, 7, 9, 11, 13] as n}
<button
type="button"
onclick={() => { bestOf = n; useCustomBo = false; customBestOf = ''; }}
class="rounded-md px-3 py-1.5 text-xs font-600 transition-colors {!useCustomBo && bestOf === n ? 'bg-accent text-surface-900' : 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
>BO{n}</button>
onclick={() => {
bestOf = n;
useCustomBo = false;
customBestOf = '';
}}
class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {!useCustomBo &&
bestOf === n
? 'bg-accent text-surface-900'
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
>BO{n}</button
>
{/each}
<input
type="number"
@@ -376,7 +422,9 @@
bind:value={customBestOf}
onfocus={() => (useCustomBo = true)}
oninput={() => (useCustomBo = true)}
class="w-20 rounded-md border {useCustomBo ? 'border-accent' : 'border-border'} bg-surface-700 px-2 py-1.5 text-xs text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
class="w-20 rounded-md border {useCustomBo
? 'border-accent'
: 'border-border'} bg-surface-700 px-2 py-1.5 text-xs text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
</div>
@@ -385,8 +433,14 @@
<!-- Team sizes (only if creator is not personal team) -->
{#if !creatorIsPersonal && creatorMaxSize > 1}
<div>
<label for="inv-ts1" class="text-xs font-500 text-text-secondary">Your Team Size</label>
<select id="inv-ts1" name="teamSize1" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-ts1" class="font-500 text-xs text-text-secondary"
>Your Team Size</label
>
<select
id="inv-ts1"
name="teamSize1"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each Array.from({ length: creatorMaxSize }, (_, i) => i + 1) as n}
<option value={n} selected={n === creatorMaxSize}>{n}v</option>
{/each}
@@ -398,8 +452,14 @@
{#if selectedInvitedTeam && !selectedInvitedTeam.isPersonal && invitedMaxSize > 1}
<div>
<label for="inv-ts2" class="text-xs font-500 text-text-secondary">Opponent Team Size</label>
<select id="inv-ts2" name="teamSize2" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-ts2" class="font-500 text-xs text-text-secondary"
>Opponent Team Size</label
>
<select
id="inv-ts2"
name="teamSize2"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each Array.from({ length: invitedMaxSize }, (_, i) => i + 1) as n}
<option value={n} selected={n === invitedMaxSize}>v{n}</option>
{/each}
@@ -411,8 +471,12 @@
<!-- Scoring -->
<div>
<label for="inv-scoring" class="text-xs font-500 text-text-secondary">Scoring</label>
<select id="inv-scoring" name="scoringType" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-scoring" class="font-500 text-xs text-text-secondary">Scoring</label>
<select
id="inv-scoring"
name="scoringType"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="score_v2" selected>ScoreV2</option>
<option value="score">Score</option>
<option value="accuracy">Accuracy</option>
@@ -422,8 +486,13 @@
<!-- Mappool -->
<div>
<label for="inv-mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
<select id="inv-mappool" name="mappool" required class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none">
<label for="inv-mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
<select
id="inv-mappool"
name="mappool"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">Select mappool...</option>
{#each data.mappools as p}
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
@@ -435,7 +504,12 @@
<!-- ELO toggle (default on) -->
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-xs text-text-secondary">
<input type="checkbox" name="allowEloChange" checked class="rounded border-border bg-surface-700 text-accent focus:ring-accent" />
<input
type="checkbox"
name="allowEloChange"
checked
class="rounded border-border bg-surface-700 text-accent focus:ring-accent"
/>
Affects ELO
</label>
</div>
@@ -443,18 +517,37 @@
<!-- Message + Schedule -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="inv-message" class="text-xs font-500 text-text-secondary">Message (optional)</label>
<input type="text" id="inv-message" name="message" placeholder="e.g. ggs only" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none" />
<label for="inv-message" class="font-500 text-xs text-text-secondary"
>Message (optional)</label
>
<input
type="text"
id="inv-message"
name="message"
placeholder="e.g. ggs only"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
<div>
<label for="inv-schedule" class="text-xs font-500 text-text-secondary">Schedule (optional)</label>
<input type="datetime-local" id="inv-schedule" name="scheduledAt" class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none" />
<label for="inv-schedule" class="font-500 text-xs text-text-secondary"
>Schedule (optional)</label
>
<input
type="datetime-local"
id="inv-schedule"
name="scheduledAt"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="submit" disabled={!selectedInvitedTeamId} class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-40">
<button
type="submit"
disabled={!selectedInvitedTeamId}
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-40"
>
Send Challenge
</button>
</div>
@@ -464,7 +557,7 @@
<!-- Live Matches -->
{#if liveMatches.length > 0}
<div class="mt-6">
<h2 class="flex items-center gap-2 text-sm font-600">
<h2 class="font-600 flex items-center gap-2 text-sm">
<div class="relative h-2 w-2">
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
@@ -483,24 +576,24 @@
{#if p1?.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
<span class="text-xs font-600 text-text-secondary">vs</span>
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
<span class="font-800 text-xl tabular-nums">{p1?.score ?? 0}</span>
<span class="font-600 text-xs text-text-secondary">vs</span>
<span class="font-800 text-xl tabular-nums">{p2?.score ?? 0}</span>
</div>
<div class="flex flex-1 items-center justify-end gap-3">
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
{#if p2?.team.avatarUrl}
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{/if}
</div>
<span
class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ??
class="font-500 flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs {sc?.color ??
'border-border'}"
>
{#if sc?.dot}
@@ -517,7 +610,7 @@
<!-- All Matches -->
<div class="mt-6">
{#if liveMatches.length > 0}
<h2 class="text-sm font-600 text-text-secondary">Past Matches</h2>
<h2 class="font-600 text-sm text-text-secondary">Past Matches</h2>
{/if}
<div class="mt-3 flex flex-col gap-2">
{#each recentMatches as m}
@@ -531,40 +624,45 @@
<!-- Match info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
{#if m.state === 'FINISHED'}
<span
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
class="font-700 text-xs tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
? 'text-green-400'
: 'text-text-secondary'}">{p1?.score ?? 0}</span
>
<span class="text-xs text-text-secondary">-</span>
<span
class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
class="font-700 text-xs tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
? 'text-green-400'
: 'text-text-secondary'}">{p2?.score ?? 0}</span
>
{:else}
<span class="text-xs text-text-secondary">vs</span>
{/if}
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
</div>
<p class="mt-0.5 text-xs text-text-secondary">{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt} &middot; {timeAgo(m.finishedAt)}{:else if m.createdAt} &middot; {timeAgo(m.createdAt)}{/if}</p>
<p class="mt-0.5 text-xs text-text-secondary">
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt}
&middot; {timeAgo(m.finishedAt)}{:else if m.createdAt}
&middot; {timeAgo(m.createdAt)}{/if}
</p>
{#if m.mappool}
<p class="text-xs text-text-secondary">{m.mappool.name}{#if avgSR(m.mappool)} ({avgSR(m.mappool)}★){/if}</p>
<p class="text-xs text-text-secondary">
{m.mappool.name}{#if avgSR(m.mappool)}
({avgSR(m.mappool)}★){/if}
</p>
{/if}
</div>
<!-- Winner indicator -->
{#if m.state === 'FINISHED' && m.winnerId}
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
<span class="text-xs font-500 text-green-400">🏆 {winner?.team.name}</span>
<span class="font-500 text-xs text-green-400">🏆 {winner?.team.name}</span>
{/if}
<!-- State -->
<span
class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}"
>
<span class="font-500 rounded border px-2 py-0.5 text-xs {sc?.color ?? 'border-border'}">
{sc?.label ?? m.state}
</span>
</a>

View File

@@ -13,7 +13,6 @@ import { eq } from 'drizzle-orm';
import type { MatchConfig } from '$lib/server/match/types';
import { playerRating } from '$lib/server/db/schema';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!locals.user) redirect(302, '/');
@@ -45,7 +44,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (slot.title !== null) {
// Sanitize any stale ppy.sh fallback URLs (image was unavailable when first fetched)
const coverUrl = slot.coverUrl?.includes('ppy.sh') ? null : (slot.coverUrl ?? null);
const listCoverUrl = slot.listCoverUrl?.includes('ppy.sh') ? null : (slot.listCoverUrl ?? null);
const listCoverUrl = slot.listCoverUrl?.includes('ppy.sh')
? null
: (slot.listCoverUrl ?? null);
beatmapCache[slot.beatmapId] = {
title: slot.title,
artist: slot.artist ?? '',
@@ -90,7 +91,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
totalLength: bm.total_length
})
.where(eq(mappoolSlot.id, slot.id));
} catch { /* skip */ }
} catch {
/* skip */
}
})
);
}
@@ -120,7 +123,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}
}
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames, playerRatings };
return {
match: m,
beatmapCache,
userId: locals.user.id,
isStaff,
lobbyStatus,
playerNames,
playerRatings
};
};
export const actions: Actions = {
@@ -171,7 +182,9 @@ export const actions: Actions = {
// Announce in IRC lobby
const lobby = getLobby(params.id);
if (lobby) {
lobby.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`).catch(() => {});
lobby
.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`)
.catch(() => {});
// Check if all rolled → announce pick order or tie
const updated = await getMatchFull(params.id);
@@ -184,13 +197,17 @@ export const actions: Actions = {
const isTBRoll = updated.participants.every((p: any) => p.score === rollWinsNeeded - 1);
if (isTBRoll) {
lobby.chat(
lobby
.chat(
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
).catch(() => {});
)
.catch(() => {});
} else {
lobby.chat(
lobby
.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
).catch(() => {});
)
.catch(() => {});
}
} else if (isLastRoll && updated.participants.every((p: any) => p.rollValue === null)) {
lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`).catch(() => {});
@@ -210,9 +227,7 @@ export const actions: Actions = {
const m = await getMatchFull(params.id);
// Find the expected picker based on pick order and game count
const sorted = [...m.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const expectedIdx = m.games.length % sorted.length;
const expectedPicker = sorted[expectedIdx];
@@ -220,9 +235,7 @@ export const actions: Actions = {
// ── Verify the logged-in user is on the expected picker's team ──
// No staff bypass here — even admins can't pick for another team
const isOnPickerTeam = expectedPicker.players.some(
(pl) => pl.userId === locals.user!.id
);
const isOnPickerTeam = expectedPicker.players.some((pl) => pl.userId === locals.user!.id);
if (!isOnPickerTeam) {
return { error: "It's not your turn to pick" };
}

View File

@@ -38,9 +38,7 @@
);
// Should the mappool be visible?
const showMappool = $derived(
['PICKING', 'PLAYING', 'ROLLING', 'FINISHED'].includes(m.state)
);
const showMappool = $derived(['PICKING', 'PLAYING', 'ROLLING', 'FINISHED'].includes(m.state));
// Can picks actually be made right now?
const pickingPhase = $derived(m.state === 'PICKING');
@@ -77,7 +75,9 @@
let now = $state(Date.now());
$effect(() => {
if (m.startedAt && !['FINISHED', 'CANCELLED'].includes(m.state)) {
const interval = setInterval(() => { now = Date.now(); }, 1000);
const interval = setInterval(() => {
now = Date.now();
}, 1000);
return () => clearInterval(interval);
}
});
@@ -99,7 +99,8 @@
const mappoolAvgSR = $derived(() => {
const slots = m.mappool?.slots;
if (!slots?.length) return null;
const avg = slots.reduce((sum: number, s: unknown) => sum + (s.starRating ?? 0), 0) / slots.length;
const avg =
slots.reduce((sum: number, s: unknown) => sum + (s.starRating ?? 0), 0) / slots.length;
return avg.toFixed(2);
});
@@ -129,16 +130,44 @@
}
function stateLabel(s: string) {
return ({ CREATED: 'Waiting', LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Pick Phase', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled' })[s] ?? s;
return (
{
CREATED: 'Waiting',
LOBBY: 'In Lobby',
ROLLING: 'Rolling',
PICKING: 'Pick Phase',
PLAYING: 'Playing',
FINISHED: 'Finished',
CANCELLED: 'Cancelled'
}[s] ?? s
);
}
function stateBorder(s: string) {
return ({ ROLLING: 'border-yellow-500/30', PICKING: 'border-blue-500/30', PLAYING: 'border-green-500/30', FINISHED: 'border-accent/30' })[s] ?? 'border-border';
return (
{
ROLLING: 'border-yellow-500/30',
PICKING: 'border-blue-500/30',
PLAYING: 'border-green-500/30',
FINISHED: 'border-accent/30'
}[s] ?? 'border-border'
);
}
const stateLabels: Record<string, string> = { LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Picking', PLAYING: 'Live Now', FINISHED: 'Finished', CANCELLED: 'Cancelled' };
const ogTitle = $derived(`${p1?.team?.name ?? 'TBD'} vs ${p2?.team?.name ?? 'TBD'} — ${m.name} | Vash Esports`);
const ogDesc = $derived(`${stateLabels[m.state] ?? m.state} · Best of ${(m.config as any)?.bestOf ?? '?'} · Watch on Vash Esports`);
const stateLabels: Record<string, string> = {
LOBBY: 'In Lobby',
ROLLING: 'Rolling',
PICKING: 'Picking',
PLAYING: 'Live Now',
FINISHED: 'Finished',
CANCELLED: 'Cancelled'
};
const ogTitle = $derived(
`${p1?.team?.name ?? 'TBD'} vs ${p2?.team?.name ?? 'TBD'} — ${m.name} | Vash Esports`
);
const ogDesc = $derived(
`${stateLabels[m.state] ?? m.state} · Best of ${(m.config as any)?.bestOf ?? '?'} · Watch on Vash Esports`
);
</script>
<svelte:head>
@@ -155,10 +184,17 @@
<!-- Shared reinvite snippet -->
{#snippet reinviteButton()}
<form method="post" action="?/reinvite" use:enhance={() => {
<form
method="post"
action="?/reinvite"
use:enhance={() => {
reinviting = true;
return async ({ update }) => { reinviting = false; await update(); };
}}>
return async ({ update }) => {
reinviting = false;
await update();
};
}}
>
<button
type="submit"
disabled={reinviting}
@@ -172,24 +208,39 @@
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="flex items-center gap-3">
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary">&larr;</a>
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary"
>&larr;</a
>
<div class="flex-1">
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
<h1 class="font-700 text-xl tracking-tight">{m.name || 'Match'}</h1>
<p class="mt-0.5 text-xs text-text-secondary">
Best of {config.bestOf} &middot; First to {winsNeeded}
{#if duration}
&middot; {duration}
{/if}
{#if m.osuLobbyId}
&middot; <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
&middot; <a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a
>
{/if}
</p>
</div>
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
<span class="font-600 rounded border px-2.5 py-1 text-xs {stateBorder(m.state)}"
>{stateLabel(m.state)}</span
>
{#if !['FINISHED', 'CANCELLED'].includes(m.state) && data.isStaff}
<form method="post" action="?/cancel" use:enhance>
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
<button
type="submit"
onclick={(e) => {
if (!confirm('Cancel this match?')) e.preventDefault();
}}
class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>Cancel</button
>
</form>
{/if}
</div>
@@ -197,12 +248,16 @@
<!-- Scoreboard -->
{#if p1 && p2}
<div class="mt-6 flex items-center gap-4">
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
<div
class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId
? 'border-green-500/40 bg-green-500/5'
: 'border-border bg-surface-800'}"
>
{#if p1.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
{/if}
<div class="flex-1">
<p class="text-sm font-600">{p1.team.name}</p>
<p class="font-600 text-sm">{p1.team.name}</p>
{#if config.teamSize === 1}
{@const r1 = data.playerRatings?.[p1.players[0]?.userId]}
{#if r1}
@@ -210,18 +265,32 @@
{/if}
{/if}
{#if p1.rollValue != null}
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
<p class="text-xs text-text-secondary">
Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}
</p>
{/if}
</div>
<span class="text-3xl font-800 tabular-nums {p1.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p1.score}</span>
<span
class="font-800 text-3xl tabular-nums {p1.score >= winsNeeded
? 'text-green-400'
: 'text-text-primary'}">{p1.score}</span
>
</div>
<span class="text-lg font-700 text-text-secondary">vs</span>
<span class="font-700 text-lg text-text-secondary">vs</span>
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
<span class="text-3xl font-800 tabular-nums {p2.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p2.score}</span>
<div
class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId
? 'border-green-500/40 bg-green-500/5'
: 'border-border bg-surface-800'}"
>
<span
class="font-800 text-3xl tabular-nums {p2.score >= winsNeeded
? 'text-green-400'
: 'text-text-primary'}">{p2.score}</span
>
<div class="flex-1 text-right">
<p class="text-sm font-600">{p2.team.name}</p>
<p class="font-600 text-sm">{p2.team.name}</p>
{#if config.teamSize === 1}
{@const r2 = data.playerRatings?.[p2.players[0]?.userId]}
{#if r2}
@@ -229,7 +298,9 @@
{/if}
{/if}
{#if p2.rollValue != null}
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
<p class="text-xs text-text-secondary">
{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}
</p>
{/if}
</div>
{#if p2.team.avatarUrl}
@@ -244,7 +315,11 @@
<div class="mt-3 flex items-center justify-center gap-3">
{@render reinviteButton()}
{#if m.osuLobbyId}
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby →</a>
<a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-xs text-text-secondary hover:text-accent">Open lobby →</a
>
{/if}
</div>
{/if}
@@ -252,41 +327,66 @@
<!-- ROLLING MODAL (stays as overlay — it's a brief phase) -->
{#if m.state === 'ROLLING'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
<h2 class="text-lg font-700">{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}</h2>
<p class="mt-2 text-sm text-text-secondary">{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}</p>
<div
class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl"
>
<h2 class="font-700 text-lg">
{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}
</h2>
<p class="mt-2 text-sm text-text-secondary">
{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}
</p>
<div class="mt-6 flex justify-center gap-6">
{#each m.participants as p}
<div class="rounded-lg border border-border bg-surface-700 px-6 py-4">
<p class="text-xs text-text-secondary">{p.team.name}</p>
{#if p.rollValue != null}
<p class="mt-1 text-2xl font-800 text-yellow-400">{p.rollValue}</p>
<p class="font-800 mt-1 text-2xl text-yellow-400">{p.rollValue}</p>
{:else}
<p class="mt-1 text-2xl font-800 text-text-secondary/30">—</p>
<p class="font-800 mt-1 text-2xl text-text-secondary/30">—</p>
{/if}
</div>
{/each}
</div>
{#if canRoll}
<form method="post" action="?/roll" use:enhance={() => {
<form
method="post"
action="?/roll"
use:enhance={() => {
rolling = true;
return async ({ update }) => { rolling = false; await update(); };
}}>
<button type="submit" disabled={rolling} class="mt-6 rounded-md bg-accent px-8 py-3 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
return async ({ update }) => {
rolling = false;
await update();
};
}}
>
<button
type="submit"
disabled={rolling}
class="font-600 mt-6 rounded-md bg-accent px-8 py-3 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{rolling ? 'Rolling...' : '🎲 Roll!'}
</button>
</form>
<p class="mt-3 text-xs text-text-secondary">or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!roll</code> in osu! chat</p>
<p class="mt-3 text-xs text-text-secondary">
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent"
>!roll</code
> in osu! chat
</p>
{:else}
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
<p class="mt-6 animate-pulse text-sm text-text-secondary">Waiting for all rolls...</p>
{/if}
<div class="mt-6 flex flex-col items-center gap-2 border-t border-border pt-4">
{@render reinviteButton()}
{#if m.osuLobbyId}
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a>
<a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a
>
{/if}
</div>
</div>
@@ -303,13 +403,17 @@
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-3 w-3 rounded-full bg-green-400"></div>
</div>
<span class="text-sm font-600 text-green-400">
<span class="font-600 text-sm text-green-400">
{lobbyStatus?.gameInProgress ? 'Playing' : 'Now Playing — Waiting for ready'}
</span>
</div>
{#if bm}
<p class="mt-2 text-center text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="mt-1 text-center text-xs text-text-secondary">{bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}</p>
<p class="mt-2 text-center text-sm">
{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span>
</p>
<p class="mt-1 text-center text-xs text-text-secondary">
{bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}
</p>
{/if}
<!-- Per-player lobby status -->
@@ -320,17 +424,31 @@
{@const username = playerNames[player.userId]}
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
{@const isReady = username && lobbyStatus.readyPlayers.includes(username)}
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
<div
class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs"
>
<span class="font-500">{username ?? player.userId}</span>
<span class="ml-auto flex items-center gap-1.5">
{#if lobbyStatus.gameInProgress}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Playing</span>
<span
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
>Playing</span
>
{:else if isReady}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Ready</span>
<span
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
>Ready</span
>
{:else if inLobby}
<span class="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-600 text-yellow-400">In Lobby</span>
<span
class="font-600 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-400"
>In Lobby</span
>
{:else}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
<span
class="font-600 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] text-text-secondary"
>Not in lobby</span
>
{/if}
</span>
</div>
@@ -338,21 +456,28 @@
{/each}
</div>
{:else}
<p class="mt-3 text-center text-xs text-text-secondary animate-pulse">
<p class="mt-3 animate-pulse text-center text-xs text-text-secondary">
Waiting for players to ready up &amp; play in osu!...
</p>
{/if}
{#if data.isStaff}
<div class="mt-3 text-center">
<form method="post" action="?/forceStart" use:enhance={() => {
<form
method="post"
action="?/forceStart"
use:enhance={() => {
forceStarting = true;
return async ({ update }) => { forceStarting = false; await update(); };
}}>
return async ({ update }) => {
forceStarting = false;
await update();
};
}}
>
<button
type="submit"
disabled={forceStarting}
class="rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
class="font-600 rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
</button>
@@ -367,9 +492,11 @@
<div class="mt-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-sm font-600">
<h2 class="font-600 text-sm">
{#if m.mappool}
<a href="/mappools/{m.mappool.id}" class="hover:text-accent hover:underline">{m.mappool.name}</a>
<a href="/mappools/{m.mappool.id}" class="hover:text-accent hover:underline"
>{m.mappool.name}</a
>
{:else}
Mappool
{/if}
@@ -378,17 +505,21 @@
<span class="text-xs text-text-secondary">avg {mappoolAvgSR()}★</span>
{/if}
{#if pickingPhase}
<span class="rounded bg-blue-500/20 px-2 py-0.5 text-[10px] font-600 text-blue-400">
<span class="font-600 rounded bg-blue-500/20 px-2 py-0.5 text-[10px] text-blue-400">
{isMyTurnToPick ? 'Your pick!' : `${expectedPicker?.team.name}'s pick`}
</span>
{:else if m.state === 'PLAYING'}
<span class="rounded bg-green-500/20 px-2 py-0.5 text-[10px] font-600 text-green-400">In progress</span>
<span class="font-600 rounded bg-green-500/20 px-2 py-0.5 text-[10px] text-green-400"
>In progress</span
>
{:else if m.state === 'ROLLING'}
<span class="rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] font-600 text-yellow-400">Rolling...</span>
<span class="font-600 rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] text-yellow-400"
>Rolling...</span
>
{/if}
</div>
<button
onclick={() => mappoolCollapsed = !mappoolCollapsed}
onclick={() => (mappoolCollapsed = !mappoolCollapsed)}
class="text-xs text-text-secondary hover:text-text-primary"
>
{mappoolCollapsed ? 'Show ▼' : 'Hide ▲'}
@@ -397,12 +528,18 @@
{#if pickingPhase && !isMyTurnToPick}
<p class="mt-1 text-xs text-text-secondary">
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick...
You can also type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat when it's your turn.
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick... You
can also type
<code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent"
>!pick NM1</code
> in osu! chat when it's your turn.
</p>
{:else if pickingPhase && isMyTurnToPick}
<p class="mt-1 text-xs text-text-secondary">
Pick a map below, or type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat.
Pick a map below, or type <code
class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent"
>!pick NM1</code
> in osu! chat.
</p>
{/if}
@@ -415,7 +552,11 @@
{#each Object.entries(groupedSlots()) as [category, slots] (category)}
<!-- Grouped slots -->
<div>
<span class="mb-1.5 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
<span
class="font-600 mb-1.5 inline-block rounded border px-2 py-0.5 text-xs {catColors[
category
] ?? 'border-border'}"
>
{category}
</span>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
@@ -429,55 +570,82 @@
{@const slotLocked = tbLocked || matchPointLocked}
{@const cantPick = !isMyTurnToPick || !pickingPhase}
<div class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
<div
class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
? 'border-border/50 bg-surface-900 opacity-30'
: slotLocked
? 'border-border/50 bg-surface-900 opacity-30'
: 'border-border bg-surface-800'}">
: 'border-border bg-surface-800'}"
>
<a
href="https://osu.ppy.sh/beatmaps/{slot.beatmapId}"
target="_blank"
class="flex min-w-0 flex-1 items-center gap-3"
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 shrink-0 rounded object-cover" />
<img
src={bm.listCoverUrl}
alt=""
class="h-10 w-20 shrink-0 rounded object-cover"
/>
{:else}
<div class="flex h-10 w-20 shrink-0 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
<div
class="flex h-10 w-20 shrink-0 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary"
>
?
</div>
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-sm font-500">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">[{bm.version}] &middot; {bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm</p>
<p class="font-500 truncate text-sm">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">
[{bm.version}] &middot; {bm.starRating.toFixed(2)}★ &middot; {bm.bpm}bpm
</p>
{:else}
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
{/if}
</div>
</a>
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
<span class="font-600 text-xs text-text-secondary"
>{category}{slot.orderInCategory}</span
>
{#if isPlayed}
<span class="text-xs text-text-secondary">✓</span>
{:else if slotLocked}
<span class="text-[10px] text-pink-400/60" title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span>
<span
class="text-[10px] text-pink-400/60"
title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span
>
{:else if pickingPhase}
<form method="post" action="?/pick" use:enhance={() => {
<form
method="post"
action="?/pick"
use:enhance={() => {
picking = true;
pickError = '';
return async ({ result, update }) => {
picking = false;
if (result.type === 'failure' || (result.type === 'success' && (result.data as Record<string, unknown>)?.error)) {
pickError = (result.data as Record<string, unknown>)?.error as string ?? 'Pick failed';
if (
result.type === 'failure' ||
(result.type === 'success' &&
(result.data as Record<string, unknown>)?.error)
) {
pickError =
((result.data as Record<string, unknown>)?.error as string) ??
'Pick failed';
} else {
await update();
}
};
}}>
}}
>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
disabled={picking || cantPick}
class="rounded px-2 py-1 text-[10px] font-700 transition-colors {cantPick
? 'bg-surface-600 text-text-secondary/40 cursor-default'
: 'bg-white text-black hover:bg-white/80 cursor-pointer'}"
class="font-700 rounded px-2 py-1 text-[10px] transition-colors {cantPick
? 'cursor-default bg-surface-600 text-text-secondary/40'
: 'cursor-pointer bg-white text-black hover:bg-white/80'}"
>
PICK
</button>
@@ -498,10 +666,10 @@
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
<div class="mt-6 rounded-xl border border-accent/30 bg-surface-800 p-6 text-center">
<div class="text-3xl">🏆</div>
<h2 class="mt-3 text-xl font-800">
<h2 class="font-800 mt-3 text-xl">
<span class="text-accent">{winner?.team.name}</span> wins!
</h2>
<p class="mt-1 text-base font-700 tabular-nums text-text-secondary">
<p class="font-700 mt-1 text-base text-text-secondary tabular-nums">
{p1?.score} – {p2?.score}
</p>
</div>
@@ -510,15 +678,21 @@
<!-- GAME HISTORY -->
{#if m.games.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600">Games Played</h2>
<h2 class="font-600 text-sm">Games Played</h2>
<div class="mt-3 flex flex-col gap-2">
{#each m.games as game}
{@const bm = data.beatmapCache[game.slot?.beatmapId]}
{@const pickerTeam = m.participants.find((p: any) => p.id === game.pickedByParticipantId)?.team}
{@const winnerTeam = m.participants.find((p: any) => p.id === game.winnerParticipantId)?.team}
{@const pickerTeam = m.participants.find(
(p: any) => p.id === game.pickedByParticipantId
)?.team}
{@const winnerTeam = m.participants.find(
(p: any) => p.id === game.winnerParticipantId
)?.team}
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3">
<span class="w-6 text-center text-xs font-600 text-text-secondary">{game.gameNumber}</span>
<span class="font-600 w-6 text-center text-xs text-text-secondary"
>{game.gameNumber}</span
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-8 w-16 rounded object-cover" />
{:else}
@@ -526,16 +700,21 @@
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-sm font-500">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="font-500 truncate text-sm">
{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span>
</p>
{/if}
<div class="flex items-center gap-2 text-xs text-text-secondary">
<span class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ?? 'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
<span
class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ??
'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span
>
{#if pickerTeam}<span>Picked by {pickerTeam.name}</span>{/if}
</div>
</div>
{#if game.state === 'PLAYING'}
<span class="text-xs text-green-400 animate-pulse">Live</span>
<span class="animate-pulse text-xs text-green-400">Live</span>
{:else if winnerTeam}
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
{/if}
@@ -546,9 +725,13 @@
<div class="mt-1.5 flex flex-col gap-1">
{#each game.scores as s}
{@const username = playerNames[s.player?.userId]}
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs">
<span class="w-24 truncate font-500">{username ?? s.player?.userId ?? '?'}</span>
<span class="font-mono tabular-nums text-text-primary">{s.score.toLocaleString()}</span>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs"
>
<span class="font-500 w-24 truncate">{username ?? s.player?.userId ?? '?'}</span>
<span class="font-mono text-text-primary tabular-nums"
>{s.score.toLocaleString()}</span
>
{#if s.accuracy > 0}
<span class="text-text-secondary">{(s.accuracy * 100).toFixed(2)}%</span>
{/if}
@@ -581,7 +764,7 @@
<!-- CREATED / LOBBY waiting states -->
{#if m.state === 'CREATED'}
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6 text-center">
<p class="text-sm text-text-secondary animate-pulse">Creating osu! lobby...</p>
<p class="animate-pulse text-sm text-text-secondary">Creating osu! lobby...</p>
</div>
{/if}
@@ -590,7 +773,11 @@
<p class="text-sm text-text-secondary">Lobby created. Waiting for players to join...</p>
{#if m.osuLobbyId}
<p class="mt-2 text-xs text-text-secondary">
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
<a
href="https://osu.ppy.sh/mp/{m.osuLobbyId}"
target="_blank"
class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a
>
</p>
{/if}
{#if lobbyStatus}
@@ -599,13 +786,21 @@
{#each participant.players as player}
{@const username = playerNames[player.userId]}
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
<div
class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs"
>
<span class="font-500">{username ?? player.userId}</span>
<span class="ml-auto">
{#if inLobby}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">In Lobby</span>
<span
class="font-600 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"
>In Lobby</span
>
{:else}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
<span
class="font-600 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] text-text-secondary"
>Not in lobby</span
>
{/if}
</span>
</div>

View File

@@ -3,24 +3,24 @@
<meta name="description" content="Privacy Policy for Vash Esports." />
</svelte:head>
<div class="prose prose-invert mx-auto max-w-3xl">
<div class="mx-auto prose max-w-3xl prose-invert">
<h1>Privacy Policy</h1>
<p class="text-text-secondary text-sm">Last updated: {new Date().getFullYear()}</p>
<p class="text-sm text-text-secondary">Last updated: {new Date().getFullYear()}</p>
<h2>1. Information We Collect</h2>
<p>When you use Vash Esports, we collect:</p>
<ul>
<li>
<strong>osu! account data</strong> — your osu! username, user ID, and profile information
provided via osu! OAuth
<strong>osu! account data</strong> — your osu! username, user ID, and profile information provided
via osu! OAuth
</li>
<li>
<strong>Tournament data</strong> — match results, scores, and team membership you create or
participate in
<strong>Tournament data</strong> — match results, scores, and team membership you create or participate
in
</li>
<li>
<strong>Usage analytics</strong> — anonymized page views and interactions via Umami, a
privacy-focused analytics tool. No cookies are used and no personal data is collected.
<strong>Usage analytics</strong> — anonymized page views and interactions via Umami, a privacy-focused
analytics tool. No cookies are used and no personal data is collected.
</li>
</ul>
@@ -34,9 +34,10 @@
<h2>3. Data Sharing</h2>
<p>
We do not sell or share your personal data with third parties, except as required by law.
osu! account data is obtained via osu!'s OAuth API and subject to
<a href="https://osu.ppy.sh/legal/privacy" target="_blank" rel="noopener">osu!'s Privacy Policy</a
We do not sell or share your personal data with third parties, except as required by law. osu!
account data is obtained via osu!'s OAuth API and subject to
<a href="https://osu.ppy.sh/legal/privacy" target="_blank" rel="noopener"
>osu!'s Privacy Policy</a
>.
</p>

View File

@@ -29,9 +29,7 @@ export const load: PageServerLoad = async ({ locals }) => {
role: locals.user.role ?? 'player',
createdAt: locals.user.createdAt
},
rating: rating
? { elo: rating.elo, wins: rating.wins, losses: rating.losses }
: null,
rating: rating ? { elo: rating.elo, wins: rating.wins, losses: rating.losses } : null,
hasOsuLinked: !!osuAccount
};
};

View File

@@ -19,7 +19,7 @@
</script>
<div class="mx-auto max-w-2xl">
<h1 class="text-2xl font-700 tracking-tight">Settings</h1>
<h1 class="font-700 text-2xl tracking-tight">Settings</h1>
<p class="mt-1 text-sm text-text-secondary">Your profile and account settings</p>
<!-- Profile Card -->
@@ -29,15 +29,15 @@
<img src={data.profile.image} alt={data.profile.name} class="h-16 w-16 rounded-full" />
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl font-700 text-text-secondary"
class="font-700 flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl text-text-secondary"
>
{data.profile.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex items-center gap-2">
<h2 class="text-lg font-700">{data.profile.name}</h2>
<span class="rounded px-2 py-0.5 text-xs font-600 {badge.color}">
<h2 class="font-700 text-lg">{data.profile.name}</h2>
<span class="font-600 rounded px-2 py-0.5 text-xs {badge.color}">
{badge.label}
</span>
</div>
@@ -47,11 +47,13 @@
<!-- Connected Accounts -->
<div class="mt-6 border-t border-border pt-4">
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Connected Accounts</h3>
<h3 class="font-600 text-xs tracking-wider text-text-secondary uppercase">
Connected Accounts
</h3>
<div class="mt-3 flex items-center gap-3 rounded-md bg-surface-700 px-4 py-3">
<img src="/osu.png" alt="osu!" class="h-5 w-5 object-contain" />
<div class="flex-1">
<p class="text-sm font-500">osu!</p>
<p class="font-500 text-sm">osu!</p>
<p class="text-xs text-text-secondary">
{#if data.hasOsuLinked}
Connected as {data.profile.name}
@@ -61,11 +63,13 @@
</p>
</div>
{#if data.hasOsuLinked}
<span class="rounded bg-green-500/20 px-2 py-0.5 text-xs font-500 text-green-400">Linked</span>
<span class="font-500 rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400"
>Linked</span
>
{:else}
<a
href="/api/auth/osu/login"
class="rounded bg-accent px-3 py-1 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded bg-accent px-3 py-1 text-xs text-surface-900 hover:bg-accent-hover"
>
Connect
</a>
@@ -76,22 +80,24 @@
<!-- Stats Card -->
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-6">
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Ranked Stats</h3>
<h3 class="font-600 text-xs tracking-wider text-text-secondary uppercase">Ranked Stats</h3>
<div class="mt-4 grid grid-cols-4 gap-4">
<div>
<p class="text-2xl font-800 tabular-nums text-accent">{data.rating?.elo ?? '—'}</p>
<p class="font-800 text-2xl text-accent tabular-nums">{data.rating?.elo ?? '—'}</p>
<p class="mt-1 text-xs text-text-secondary">ELO Rating</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-green-400">{data.rating?.wins ?? 0}</p>
<p class="font-800 text-2xl text-green-400 tabular-nums">{data.rating?.wins ?? 0}</p>
<p class="mt-1 text-xs text-text-secondary">Wins</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-red-400">{data.rating?.losses ?? 0}</p>
<p class="font-800 text-2xl text-red-400 tabular-nums">{data.rating?.losses ?? 0}</p>
<p class="mt-1 text-xs text-text-secondary">Losses</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-text-primary">{winRate}{winRate !== '—' ? '%' : ''}</p>
<p class="font-800 text-2xl text-text-primary tabular-nums">
{winRate}{winRate !== '—' ? '%' : ''}
</p>
<p class="mt-1 text-xs text-text-secondary">Win Rate</p>
</div>
</div>
@@ -99,12 +105,12 @@
<!-- Danger Zone -->
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-6">
<h3 class="text-xs font-600 text-red-400 uppercase tracking-wider">Account</h3>
<h3 class="font-600 text-xs tracking-wider text-red-400 uppercase">Account</h3>
<div class="mt-4">
<form method="post" action="?/logout" use:enhance>
<button
type="submit"
class="rounded-md border border-red-500/30 px-4 py-2 text-sm font-500 text-red-400 transition-colors hover:bg-red-500/10"
class="font-500 rounded-md border border-red-500/30 px-4 py-2 text-sm text-red-400 transition-colors hover:bg-red-500/10"
>
Sign Out
</button>

View File

@@ -16,9 +16,15 @@
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
);
const otherTeams = $derived(
data.teams.filter((t: any) => !t.isPersonal && !t.members.some((m: any) => m.userId === data.userId))
data.teams.filter(
(t: any) => !t.isPersonal && !t.members.some((m: any) => m.userId === data.userId)
)
);
const teamCount = $derived(
data.teams.filter(
(t: any) => !t.isPersonal || t.members.some((m: any) => m.userId === data.userId)
).length
);
const teamCount = $derived(data.teams.filter((t: any) => !t.isPersonal || t.members.some((m: any) => m.userId === data.userId)).length);
function isOwner(t: any) {
return t.ownerId === data.userId;
@@ -38,14 +44,14 @@
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
<h1 class="font-700 text-2xl tracking-tight">Teams</h1>
<p class="mt-1 text-sm text-text-secondary">
{teamCount} team{teamCount !== 1 ? 's' : ''}
</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Team'}
</button>
@@ -75,7 +81,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
@@ -89,7 +95,7 @@
<!-- My Teams -->
{#if myTeams.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">My Teams</h2>
<h2 class="font-600 text-sm text-text-secondary">My Teams</h2>
<div class="mt-3 flex flex-col gap-3">
{#each myTeams as t}
<div class="rounded-lg border border-border bg-surface-800 transition-colors">
@@ -102,17 +108,21 @@
<img src={t.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm font-700 text-text-secondary"
class="font-700 flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex items-center gap-2">
<a href="/teams/{t.id}" onclick={(e: MouseEvent) => e.stopPropagation()} class="text-sm font-600 hover:text-accent hover:underline">{t.name}</a>
<a
href="/teams/{t.id}"
onclick={(e: MouseEvent) => e.stopPropagation()}
class="font-600 text-sm hover:text-accent hover:underline">{t.name}</a
>
{#if t.isPersonal}
<span
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary"
class="font-500 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
>solo</span
>
{/if}
@@ -130,7 +140,7 @@
<!-- Expanded: members -->
{#if expandedTeam === t.id}
<div class="border-t border-border px-4 pb-4 pt-3">
<div class="border-t border-border px-4 pt-3 pb-4">
<!-- Rename team -->
{#if isOwner(t) && !t.isPersonal}
{#if renamingTeam === t.id}
@@ -143,10 +153,7 @@
if (result.type === 'success' && (result.data as any)?.success) {
renamingTeam = null;
await update();
} else if (
result.type === 'success' &&
(result.data as any)?.error
) {
} else if (result.type === 'success' && (result.data as any)?.error) {
actionError = (result.data as any).error;
}
};
@@ -163,7 +170,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
>Save</button
>
<button
@@ -232,10 +239,7 @@
memberUsername = '';
addingMemberTo = null;
await update();
} else if (
result.type === 'success' &&
(result.data as any)?.error
) {
} else if (result.type === 'success' && (result.data as any)?.error) {
actionError = (result.data as any).error;
}
};
@@ -253,7 +257,7 @@
/>
<button
type="submit"
class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover"
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
>Add</button
>
<button
@@ -299,21 +303,24 @@
<!-- Other Teams -->
{#if otherTeams.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Other Teams</h2>
<h2 class="font-600 text-sm text-text-secondary">Other Teams</h2>
<div class="mt-3 flex flex-col gap-2">
{#each otherTeams as t}
<a href="/teams/{t.id}" class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700">
<a
href="/teams/{t.id}"
class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<span class="text-sm font-600">{t.name}</span>
<span class="font-600 text-sm">{t.name}</span>
<p class="text-xs text-text-secondary">
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
</p>

View File

@@ -45,7 +45,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
.slice(0, 20);
const wins = recentMatches.filter((m) => m.winnerId === params.id).length;
const losses = recentMatches.filter((m) => m.state === 'FINISHED' && m.winnerId !== params.id).length;
const losses = recentMatches.filter(
(m) => m.state === 'FINISHED' && m.winnerId !== params.id
).length;
return {
team: { ...t, members: membersWithUsers },

View File

@@ -26,15 +26,20 @@
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-14 w-14 rounded-full" />
{:else}
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-surface-600 text-lg font-700 text-text-secondary">
<div
class="font-700 flex h-14 w-14 items-center justify-center rounded-full bg-surface-600 text-lg text-text-secondary"
>
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-700 tracking-tight">{t.name}</h1>
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
{#if t.isPersonal}
<span class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary">solo</span>
<span
class="font-500 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
>solo</span
>
{/if}
</div>
<p class="mt-0.5 text-sm text-text-secondary">
@@ -46,16 +51,18 @@
<!-- Players -->
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Players</h2>
<h2 class="font-600 text-sm text-text-secondary">Players</h2>
<div class="mt-3 flex flex-col gap-2">
{#each t.members as member}
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3">
<div
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3"
>
{#if member.user?.image}
<img src={member.user.image} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div class="h-8 w-8 rounded-full bg-surface-600"></div>
{/if}
<span class="flex-1 text-sm font-600">{member.user?.name ?? 'Unknown'}</span>
<span class="font-600 flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
<span class="text-xs text-text-secondary">{member.role}</span>
</div>
{/each}
@@ -65,7 +72,7 @@
<!-- Recent Matches -->
{#if data.recentMatches.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600 text-text-secondary">Recent Matches</h2>
<h2 class="font-600 text-sm text-text-secondary">Recent Matches</h2>
<div class="mt-3 flex flex-col gap-2">
{#each data.recentMatches as m}
{@const p1 = m.participants[0]}
@@ -75,16 +82,26 @@
href="/matches/{m.id}"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
{#if m.state === 'FINISHED'}
<span class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p1?.score ?? 0}</span>
<span
class="font-700 text-xs tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
? 'text-green-400'
: 'text-text-secondary'}">{p1?.score ?? 0}</span
>
<span class="text-xs text-text-secondary">-</span>
<span class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p2?.score ?? 0}</span>
<span
class="font-700 text-xs tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
? 'text-green-400'
: 'text-text-secondary'}">{p2?.score ?? 0}</span
>
{:else}
<span class="text-xs text-text-secondary">vs</span>
{/if}
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
<span class="ml-auto text-xs {won ? 'font-600 text-green-400' : 'text-red-400'}">{won ? 'W' : 'L'}</span>
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
<span class="ml-auto text-xs {won ? 'font-600 text-green-400' : 'text-red-400'}"
>{won ? 'W' : 'L'}</span
>
<span class="text-xs text-text-secondary/50">{timeAgo(m.createdAt)}</span>
</a>
{/each}

View File

@@ -3,9 +3,9 @@
<meta name="description" content="Terms of Service for Vash Esports." />
</svelte:head>
<div class="prose prose-invert mx-auto max-w-3xl">
<div class="mx-auto prose max-w-3xl prose-invert">
<h1>Terms of Service</h1>
<p class="text-text-secondary text-sm">Last updated: {new Date().getFullYear()}</p>
<p class="text-sm text-text-secondary">Last updated: {new Date().getFullYear()}</p>
<h2>1. Acceptance of Terms</h2>
<p>
@@ -15,8 +15,8 @@
<h2>2. Use of the Service</h2>
<p>
Vash Esports is an event management platform. You may use the Service only for lawful
purposes and in accordance with these Terms. You agree not to:
Vash Esports is an event management platform. You may use the Service only for lawful purposes
and in accordance with these Terms. You agree not to:
</p>
<ul>
<li>Use the Service in any way that violates applicable laws or regulations</li>

View File

@@ -4,8 +4,8 @@
<div class="flex min-h-dvh items-center justify-center bg-surface-900">
<div class="text-center">
<p class="text-6xl font-800 text-accent">{page.status}</p>
<h1 class="mt-4 text-xl font-700 text-text-primary">
<p class="font-800 text-6xl text-accent">{page.status}</p>
<h1 class="font-700 mt-4 text-xl text-text-primary">
{#if page.status === 404}
Page not found
{:else if page.status === 403}
@@ -21,7 +21,7 @@
</p>
<a
href="/"
class="mt-6 inline-block rounded-md bg-accent px-5 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
class="font-600 mt-6 inline-block rounded-md bg-accent px-5 py-2.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Back to Dashboard
</a>

View File

@@ -2,7 +2,11 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import { isBanchoConnected, isBanchoReconnecting, getActiveLobbyCount } from '$lib/server/bancho/client';
import {
isBanchoConnected,
isBanchoReconnecting,
getActiveLobbyCount
} from '$lib/server/bancho/client';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {

View File

@@ -64,7 +64,9 @@ describe('joinQueue', () => {
}
]);
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow('You are already in an active match');
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow(
'You are already in an active match'
);
expect(mocks.db.query.playerRating.findFirst).not.toHaveBeenCalled();
});
@@ -81,7 +83,7 @@ describe('joinQueue', () => {
);
});
test('throws when user is already in queue', async() => {
test('throws when user is already in queue', async () => {
mocks.db.delete.mockReturnValue({
where: mock(() => ({ returning: mock(() => Promise.resolve([])) }))
});