use prettier formatting
This commit is contained in:
@@ -12,5 +12,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/routes/layout.css"
|
||||
"tailwindStylesheet": "./src/routes/(app)/layout.css"
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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-%'`
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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({
|
||||
name: 'Dev Test Pool',
|
||||
verifiedAt: new Date()
|
||||
}).returning();
|
||||
const [pool] = await db
|
||||
.insert(mappool)
|
||||
.values({
|
||||
name: 'Dev Test Pool',
|
||||
verifiedAt: new Date()
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[data-profile-menu]')) closeMenu();
|
||||
}} />
|
||||
<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>
|
||||
|
||||
@@ -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,26 +346,34 @@
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<!-- 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)}
|
||||
<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)}
|
||||
<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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,42 +198,56 @@ export const playerRating = pgTable('player_rating', {
|
||||
});
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────
|
||||
export const notification = pgTable('notification', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
type: text('type').notNull(),
|
||||
title: text('title').notNull(),
|
||||
message: text('message'),
|
||||
referenceId: text('reference_id'),
|
||||
read: boolean('read').default(false).notNull(),
|
||||
actionedAt: timestamp('actioned_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
}, (t) => [
|
||||
index('idx_notification_user').on(t.userId),
|
||||
index('idx_notification_user_read').on(t.userId, t.read)
|
||||
]);
|
||||
export const notification = pgTable(
|
||||
'notification',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
type: text('type').notNull(),
|
||||
title: text('title').notNull(),
|
||||
message: text('message'),
|
||||
referenceId: text('reference_id'),
|
||||
read: boolean('read').default(false).notNull(),
|
||||
actionedAt: timestamp('actioned_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(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', {
|
||||
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),
|
||||
config: jsonb('config').notNull(),
|
||||
mappoolId: uuid('mappool_id').notNull().references(() => mappool.id),
|
||||
name: text('name'),
|
||||
message: text('message'),
|
||||
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
status: text('status').default('pending').notNull(),
|
||||
matchId: uuid('match_id').references(() => match.id),
|
||||
respondedAt: timestamp('responded_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
}, (t) => [
|
||||
index('idx_invite_invited_team').on(t.invitedTeamId),
|
||||
index('idx_invite_created_by').on(t.createdBy),
|
||||
index('idx_invite_status').on(t.status)
|
||||
]);
|
||||
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),
|
||||
config: jsonb('config').notNull(),
|
||||
mappoolId: uuid('mappool_id')
|
||||
.notNull()
|
||||
.references(() => mappool.id),
|
||||
name: text('name'),
|
||||
message: text('message'),
|
||||
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
status: text('status').default('pending').notNull(),
|
||||
matchId: uuid('match_id').references(() => match.id),
|
||||
respondedAt: timestamp('responded_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(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] })
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -29,9 +29,9 @@ function sleep(ms: number) {
|
||||
// Lobby join → cancel. Rolling → auto-roll. Picking → auto-pick. Ready → force start.
|
||||
|
||||
const TIMEOUT_LOBBY_JOIN = 5 * 60 * 1000; // 5 min to join
|
||||
const TIMEOUT_ROLLING = 5 * 60 * 1000; // 5 min to complete all rolls
|
||||
const TIMEOUT_PICKING = 2 * 60 * 1000; // 2 min to pick a map
|
||||
const TIMEOUT_READY = 2 * 60 * 1000; // 2 min to ready up after map is set
|
||||
const TIMEOUT_ROLLING = 5 * 60 * 1000; // 5 min to complete all rolls
|
||||
const TIMEOUT_PICKING = 2 * 60 * 1000; // 2 min to pick a map
|
||||
const TIMEOUT_READY = 2 * 60 * 1000; // 2 min to ready up after map is set
|
||||
|
||||
const matchTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -171,12 +169,12 @@ export function onMatchStateChange(matchId: string, newState: string) {
|
||||
if (isTBAutoRoll) {
|
||||
await lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. ` +
|
||||
`Use !pick TB<n> (e.g. !pick TB1). You have 2 minutes.`
|
||||
`Use !pick TB<n> (e.g. !pick TB1). You have 2 minutes.`
|
||||
);
|
||||
} else {
|
||||
await lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. ` +
|
||||
`Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.`
|
||||
`Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -262,7 +262,7 @@ export async function initMatchLobby(matchId: string) {
|
||||
if (activeCount >= MAX_LOBBIES) {
|
||||
console.error(
|
||||
`[Orchestrator] Cannot create lobby: ${activeCount}/${MAX_LOBBIES} lobbies active. ` +
|
||||
`Close some matches first.`
|
||||
`Close some matches first.`
|
||||
);
|
||||
throw new Error(
|
||||
`Lobby limit reached (${activeCount}/${MAX_LOBBIES}). Close or finish existing matches first.`
|
||||
@@ -287,10 +287,13 @@ 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
|
||||
: 0;
|
||||
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) {
|
||||
@@ -457,12 +459,12 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
||||
if (isTBRoll) {
|
||||
await lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. ` +
|
||||
`Use !pick TB<n> (e.g. !pick TB1). You have 2 minutes.`
|
||||
`Use !pick TB<n> (e.g. !pick TB1). You have 2 minutes.`
|
||||
);
|
||||
} else {
|
||||
await lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. ` +
|
||||
`Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.`
|
||||
`Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.`
|
||||
);
|
||||
}
|
||||
} else if (isLastRoll && remaining.length === updated.participants.length) {
|
||||
@@ -472,7 +474,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
||||
// Tell the other player they still need to roll
|
||||
await lobby.chat(
|
||||
`${targetParticipant.team?.name ?? username} rolled ${value}. ` +
|
||||
`Waiting for ${remaining.map((p) => p.team.name).join(', ')} to !roll.`
|
||||
`Waiting for ${remaining.map((p) => p.team.name).join(', ')} to !roll.`
|
||||
);
|
||||
}
|
||||
} 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)
|
||||
@@ -757,7 +757,7 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
||||
|
||||
console.log(
|
||||
`[Orchestrator] osu! API returned ${osuGame.scores.length} scores for beatmap ${beatmapId}. ` +
|
||||
`Player map has ${playerOsuIdMap.size} entries.`
|
||||
`Player map has ${playerOsuIdMap.size} entries.`
|
||||
);
|
||||
|
||||
for (const s of scores) {
|
||||
@@ -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);
|
||||
|
||||
@@ -893,9 +894,11 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
||||
await sleep(1000);
|
||||
await lobby.chat(
|
||||
`🔥 Tiebreaker! ${matchScoreStr} — ${tbSlots.length} tiebreaker maps available. ` +
|
||||
`Roll to decide who picks! Type !roll. You have 5 minutes.`
|
||||
`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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -67,11 +67,4 @@ function stopPolling() {
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
queue,
|
||||
fetchQueueStatus,
|
||||
joinQueue,
|
||||
leaveQueue,
|
||||
startPolling,
|
||||
stopPolling
|
||||
};
|
||||
export { queue, fetchQueueStatus, joinQueue, leaveQueue, startPolling, stopPolling };
|
||||
|
||||
@@ -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 →
|
||||
</a>
|
||||
|
||||
@@ -17,23 +17,30 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
}
|
||||
|
||||
// ── LOGGED IN → dashboard data ──
|
||||
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),
|
||||
limit: 5
|
||||
}),
|
||||
db.query.match.findMany({
|
||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||
}),
|
||||
db.select({ count: count() }).from(match),
|
||||
db.select({ count: count() }).from(team),
|
||||
db.select({ count: count() }).from(mappool),
|
||||
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
|
||||
getQueueStatus(locals.user.id)
|
||||
]);
|
||||
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),
|
||||
limit: 5
|
||||
}),
|
||||
db.query.match.findMany({
|
||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||
}),
|
||||
db.select({ count: count() }).from(match),
|
||||
db.select({ count: count() }).from(team),
|
||||
db.select({ count: count() }).from(mappool),
|
||||
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
|
||||
getQueueStatus(locals.user.id)
|
||||
]);
|
||||
|
||||
return {
|
||||
authenticated: true as const,
|
||||
|
||||
@@ -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} · {timeAgo(m.finishedAt ?? m.createdAt)}</p>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">
|
||||
{m.name ? `${m.name} · ` : ''}BO{config.bestOf} · {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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
✓ 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
|
||||
)}★</span
|
||||
>
|
||||
<span>{slot.beatmap.starRating.toFixed(2)}★</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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 & 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">×</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">×</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} · {timeAgo(m.finishedAt)}{:else if m.createdAt} · {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}
|
||||
· {timeAgo(m.finishedAt)}{:else if m.createdAt}
|
||||
· {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>
|
||||
|
||||
@@ -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(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
|
||||
).catch(() => {});
|
||||
lobby
|
||||
.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB<n> or pick in web UI.`
|
||||
)
|
||||
.catch(() => {});
|
||||
} else {
|
||||
lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||
).catch(() => {});
|
||||
lobby
|
||||
.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||
)
|
||||
.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" };
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
reinviting = true;
|
||||
return async ({ update }) => { reinviting = false; await update(); };
|
||||
}}>
|
||||
<form
|
||||
method="post"
|
||||
action="?/reinvite"
|
||||
use:enhance={() => {
|
||||
reinviting = true;
|
||||
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">←</a>
|
||||
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary"
|
||||
>←</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} · First to {winsNeeded}
|
||||
{#if duration}
|
||||
· {duration}
|
||||
{/if}
|
||||
{#if m.osuLobbyId}
|
||||
· <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
|
||||
>
|
||||
{/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={() => {
|
||||
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">
|
||||
<form
|
||||
method="post"
|
||||
action="?/roll"
|
||||
use:enhance={() => {
|
||||
rolling = true;
|
||||
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)}★ · {bm.bpm}bpm · {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)}★ · {bm.bpm}bpm · {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 & play in osu!...
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if data.isStaff}
|
||||
<div class="mt-3 text-center">
|
||||
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||
forceStarting = true;
|
||||
return async ({ update }) => { forceStarting = false; await update(); };
|
||||
}}>
|
||||
<form
|
||||
method="post"
|
||||
action="?/forceStart"
|
||||
use:enhance={() => {
|
||||
forceStarting = true;
|
||||
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
|
||||
? 'border-border/50 bg-surface-900 opacity-30'
|
||||
: slotLocked
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
|
||||
? 'border-border/50 bg-surface-900 opacity-30'
|
||||
: 'border-border bg-surface-800'}">
|
||||
: slotLocked
|
||||
? 'border-border/50 bg-surface-900 opacity-30'
|
||||
: '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}] · {bm.starRating.toFixed(2)}★ · {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}] · {bm.starRating.toFixed(2)}★ · {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={() => {
|
||||
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';
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}>
|
||||
<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';
|
||||
} 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ const mocks = {
|
||||
query: {
|
||||
matchParticipantPlayer: { findMany: mock() },
|
||||
playerRating: { findFirst: mock() },
|
||||
matchQueue: { findFirst: mock() }
|
||||
matchQueue: { findFirst: mock() }
|
||||
},
|
||||
delete: mock()
|
||||
}
|
||||
@@ -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,15 +83,15 @@ describe('joinQueue', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when user is already in queue', async() => {
|
||||
mocks.db.delete.mockReturnValue({
|
||||
where: mock(() => ({ returning: mock(() => Promise.resolve([])) }))
|
||||
});
|
||||
test('throws when user is already in queue', async () => {
|
||||
mocks.db.delete.mockReturnValue({
|
||||
where: mock(() => ({ returning: mock(() => Promise.resolve([])) }))
|
||||
});
|
||||
|
||||
mocks.db.query.matchParticipantPlayer.findMany.mockResolvedValue([]);
|
||||
mocks.db.query.playerRating.findFirst.mockResolvedValue({ elo: 1500 });
|
||||
mocks.db.query.matchQueue.findFirst.mockResolvedValue({ userId: 'user-1', teamId: 'team-1' });
|
||||
mocks.db.query.matchParticipantPlayer.findMany.mockResolvedValue([]);
|
||||
mocks.db.query.playerRating.findFirst.mockResolvedValue({ elo: 1500 });
|
||||
mocks.db.query.matchQueue.findFirst.mockResolvedValue({ userId: 'user-1', teamId: 'team-1' });
|
||||
|
||||
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow('Already in queue');
|
||||
});
|
||||
await expect(joinQueue('user-1', 'team-1')).rejects.toThrow('Already in queue');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user