add custom matches
This commit is contained in:
237
drizzle/0000_tricky_jane_foster.sql
Normal file
237
drizzle/0000_tricky_jane_foster.sql
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
CREATE TABLE "mappool" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"created_by" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"verified_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "mappool_slot" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"mappool_id" uuid NOT NULL,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"order_in_category" integer NOT NULL,
|
||||||
|
"beatmap_id" text NOT NULL,
|
||||||
|
"star_rating" real,
|
||||||
|
"bpm" real,
|
||||||
|
"total_length" integer,
|
||||||
|
"mods" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"title" text,
|
||||||
|
"artist" text,
|
||||||
|
"version" text,
|
||||||
|
"cover_url" text,
|
||||||
|
"list_cover_url" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"state" text DEFAULT 'CREATED' NOT NULL,
|
||||||
|
"config" jsonb NOT NULL,
|
||||||
|
"mappool_id" uuid,
|
||||||
|
"osu_lobby_id" integer,
|
||||||
|
"scheduled_at" timestamp with time zone,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"winner_id" uuid,
|
||||||
|
"created_by" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match_game" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"match_id" uuid NOT NULL,
|
||||||
|
"game_number" integer NOT NULL,
|
||||||
|
"mappool_slot_id" uuid NOT NULL,
|
||||||
|
"picked_by_participant_id" uuid,
|
||||||
|
"winner_participant_id" uuid,
|
||||||
|
"state" text DEFAULT 'PENDING' NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"finished_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match_game_score" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"match_game_id" uuid NOT NULL,
|
||||||
|
"player_id" uuid NOT NULL,
|
||||||
|
"score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"accuracy" real DEFAULT 0 NOT NULL,
|
||||||
|
"max_combo" integer DEFAULT 0 NOT NULL,
|
||||||
|
"count_300" integer DEFAULT 0 NOT NULL,
|
||||||
|
"count_100" integer DEFAULT 0 NOT NULL,
|
||||||
|
"count_50" integer DEFAULT 0 NOT NULL,
|
||||||
|
"count_miss" integer DEFAULT 0 NOT NULL,
|
||||||
|
"mods" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"passed" boolean DEFAULT false NOT NULL,
|
||||||
|
"pp" real
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match_invite" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"creator_team_id" uuid NOT NULL,
|
||||||
|
"invited_team_id" uuid NOT NULL,
|
||||||
|
"config" jsonb NOT NULL,
|
||||||
|
"mappool_id" uuid NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"message" text,
|
||||||
|
"scheduled_at" timestamp with time zone,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"match_id" uuid,
|
||||||
|
"responded_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match_participant" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"match_id" uuid NOT NULL,
|
||||||
|
"team_id" uuid NOT NULL,
|
||||||
|
"slot" integer NOT NULL,
|
||||||
|
"score" integer DEFAULT 0 NOT NULL,
|
||||||
|
"roll_value" integer,
|
||||||
|
"pick_order" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match_participant_player" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"participant_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "match_queue" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"team_id" uuid NOT NULL,
|
||||||
|
"elo" integer DEFAULT 1000 NOT NULL,
|
||||||
|
"joined_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"message" text,
|
||||||
|
"reference_id" text,
|
||||||
|
"read" boolean DEFAULT false NOT NULL,
|
||||||
|
"actioned_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "player_rating" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"elo" integer DEFAULT 1000 NOT NULL,
|
||||||
|
"wins" integer DEFAULT 0 NOT NULL,
|
||||||
|
"losses" integer DEFAULT 0 NOT NULL,
|
||||||
|
"initial_elo" integer,
|
||||||
|
"osu_rank_at_seed" integer,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "player_rating_user_id_unique" UNIQUE("user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "team" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"is_personal" boolean DEFAULT false NOT NULL,
|
||||||
|
"owner_id" text,
|
||||||
|
"avatar_url" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "team_member" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"team_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role" text DEFAULT 'member',
|
||||||
|
"joined_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp,
|
||||||
|
"refresh_token_expires_at" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"role" text DEFAULT 'player' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "mappool_slot" ADD CONSTRAINT "mappool_slot_mappool_id_mappool_id_fk" FOREIGN KEY ("mappool_id") REFERENCES "public"."mappool"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match" ADD CONSTRAINT "match_mappool_id_mappool_id_fk" FOREIGN KEY ("mappool_id") REFERENCES "public"."mappool"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match" ADD CONSTRAINT "match_winner_id_team_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_match_id_match_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."match"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_mappool_slot_id_mappool_slot_id_fk" FOREIGN KEY ("mappool_slot_id") REFERENCES "public"."mappool_slot"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_picked_by_participant_id_match_participant_id_fk" FOREIGN KEY ("picked_by_participant_id") REFERENCES "public"."match_participant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_game" ADD CONSTRAINT "match_game_winner_participant_id_match_participant_id_fk" FOREIGN KEY ("winner_participant_id") REFERENCES "public"."match_participant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_game_score" ADD CONSTRAINT "match_game_score_match_game_id_match_game_id_fk" FOREIGN KEY ("match_game_id") REFERENCES "public"."match_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_game_score" ADD CONSTRAINT "match_game_score_player_id_match_participant_player_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."match_participant_player"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_creator_team_id_team_id_fk" FOREIGN KEY ("creator_team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_invited_team_id_team_id_fk" FOREIGN KEY ("invited_team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_mappool_id_mappool_id_fk" FOREIGN KEY ("mappool_id") REFERENCES "public"."mappool"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_invite" ADD CONSTRAINT "match_invite_match_id_match_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."match"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_participant" ADD CONSTRAINT "match_participant_match_id_match_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."match"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_participant" ADD CONSTRAINT "match_participant_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_participant_player" ADD CONSTRAINT "match_participant_player_participant_id_match_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."match_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "match_queue" ADD CONSTRAINT "match_queue_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_match_state" ON "match" USING btree ("state");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_match_game_match" ON "match_game" USING btree ("match_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_mgs_game" ON "match_game_score" USING btree ("match_game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_invite_invited_team" ON "match_invite" USING btree ("invited_team_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_invite_created_by" ON "match_invite" USING btree ("created_by");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_invite_status" ON "match_invite" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_mp_match_team" ON "match_participant" USING btree ("match_id","team_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_mp_match_slot" ON "match_participant" USING btree ("match_id","slot");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_mp_match" ON "match_participant" USING btree ("match_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_mpp_participant_user" ON "match_participant_player" USING btree ("participant_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_mpp_participant" ON "match_participant_player" USING btree ("participant_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_queue_user" ON "match_queue" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_queue_elo" ON "match_queue" USING btree ("elo");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_notification_user" ON "notification" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_notification_user_read" ON "notification" USING btree ("user_id","read");--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
||||||
1760
drizzle/meta/0000_snapshot.json
Normal file
1760
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773082177642,
|
||||||
|
"tag": "0000_tricky_jane_foster",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
56
scripts/backfill-personal-team-names.ts
Normal file
56
scripts/backfill-personal-team-names.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Backfill personal team names to match the player's current username.
|
||||||
|
* Removes any " (personal team)" suffix or other naming inconsistencies.
|
||||||
|
*
|
||||||
|
* Usage: bun scripts/backfill-personal-team-names.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { pgTable, text, uuid, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
if (!DATABASE_URL) throw new Error('DATABASE_URL not set');
|
||||||
|
|
||||||
|
const client = postgres(DATABASE_URL);
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
const team = pgTable('team', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
isPersonal: boolean('is_personal').notNull(),
|
||||||
|
ownerId: text('owner_id')
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = pgTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const personalTeams = await db.select().from(team).where(eq(team.isPersonal, true));
|
||||||
|
console.log(`Found ${personalTeams.length} personal teams`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const t of personalTeams) {
|
||||||
|
if (!t.ownerId) continue;
|
||||||
|
|
||||||
|
const [owner] = await db.select().from(user).where(eq(user.id, t.ownerId));
|
||||||
|
if (!owner) continue;
|
||||||
|
|
||||||
|
if (t.name !== owner.name) {
|
||||||
|
await db.update(team).set({ name: owner.name }).where(eq(team.id, t.id));
|
||||||
|
console.log(` "${t.name}" -> "${owner.name}"`);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated ${updated} personal team names`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -8,8 +8,12 @@
|
|||||||
|
|
||||||
const user = $derived(page.data?.user);
|
const user = $derived(page.data?.user);
|
||||||
const activeMatch = $derived(page.data?.activeMatch);
|
const activeMatch = $derived(page.data?.activeMatch);
|
||||||
|
const unreadCount = $derived((page.data as any)?.unreadNotificationCount ?? 0);
|
||||||
|
|
||||||
let { open = $bindable(false) } = $props();
|
let { open = $bindable(false) } = $props();
|
||||||
|
let notifOpen = $state(false);
|
||||||
|
let notifications = $state<any[]>([]);
|
||||||
|
let loadingNotifs = $state(false);
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
if (href === '/') return page.url.pathname === '/';
|
if (href === '/') return page.url.pathname === '/';
|
||||||
@@ -22,7 +26,66 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
page.url.pathname;
|
page.url.pathname;
|
||||||
open = false;
|
open = false;
|
||||||
|
notifOpen = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function toggleNotifications() {
|
||||||
|
notifOpen = !notifOpen;
|
||||||
|
if (notifOpen) {
|
||||||
|
await loadNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
loadingNotifs = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications');
|
||||||
|
if (res.ok) notifications = await res.json();
|
||||||
|
} finally {
|
||||||
|
loadingNotifs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAcceptInvite(referenceId: string) {
|
||||||
|
const res = await fetch(`/api/invites/${referenceId}/accept`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const match = await res.json();
|
||||||
|
notifications = notifications.map((n) =>
|
||||||
|
n.referenceId === referenceId ? { ...n, actionedAt: new Date().toISOString(), read: true } : n
|
||||||
|
);
|
||||||
|
window.location.href = `/matches/${match.id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeclineInvite(referenceId: string) {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
await fetch('/api/notifications/read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ all: true })
|
||||||
|
});
|
||||||
|
notifications = notifications.map((n) => ({ ...n, read: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet navIcon(name: string)}
|
{#snippet navIcon(name: string)}
|
||||||
@@ -57,15 +120,87 @@
|
|||||||
? 'translate-x-0'
|
? 'translate-x-0'
|
||||||
: '-translate-x-full'} lg:translate-x-0 lg:z-40"
|
: '-translate-x-full'} lg:translate-x-0 lg:z-40"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo + Notifications -->
|
||||||
<a href="/" class="flex items-center gap-2.5 px-5 py-5">
|
<div class="flex items-center justify-between px-5 py-5">
|
||||||
<img src="/logo.png" alt="Vash Esports" class="h-8 w-8" />
|
<a href="/" class="flex items-center gap-2.5">
|
||||||
<span class="font-700 font-bold text-base tracking-tight text-text-primary">Vash Esports</span>
|
<img src="/logo.png" alt="Vash Esports" class="h-8 w-8" />
|
||||||
</a>
|
<span class="font-700 font-bold text-base tracking-tight text-text-primary">Vash Esports</span>
|
||||||
|
</a>
|
||||||
|
{#if user}
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
onclick={toggleNotifications}
|
||||||
|
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>
|
||||||
|
{#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>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if notifOpen}
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<button
|
||||||
|
class="fixed inset-0 z-50"
|
||||||
|
onclick={() => (notifOpen = false)}
|
||||||
|
aria-label="Close notifications"
|
||||||
|
></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="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<span class="text-xs font-600 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>
|
||||||
|
{/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>
|
||||||
|
{:else}
|
||||||
|
{#each notifications as n}
|
||||||
|
<div class="border-b border-border/50 px-3 py-2.5 {n.read ? 'opacity-60' : ''}">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
{#if !n.read}
|
||||||
|
<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>
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Queue Button -->
|
<!-- Queue Button -->
|
||||||
<div class="px-3 pb-4">
|
<div class="px-3 pb-4">
|
||||||
{#if activeMatch}
|
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
|
||||||
<a
|
<a
|
||||||
href="/matches/{activeMatch.id}"
|
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="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"
|
||||||
@@ -90,7 +225,7 @@
|
|||||||
Leave Queue
|
Leave Queue
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !activeMatch}
|
||||||
<button
|
<button
|
||||||
onclick={joinQueue}
|
onclick={joinQueue}
|
||||||
disabled={queue.loading}
|
disabled={queue.loading}
|
||||||
@@ -108,7 +243,7 @@
|
|||||||
{ href: '/', label: 'Dashboard', icon: 'dashboard' },
|
{ href: '/', label: 'Dashboard', icon: 'dashboard' },
|
||||||
{ href: '/matches', label: 'Matches', icon: 'matches' },
|
{ href: '/matches', label: 'Matches', icon: 'matches' },
|
||||||
{ href: '/mappools', label: 'Mappools', icon: 'mappools' },
|
{ href: '/mappools', label: 'Mappools', icon: 'mappools' },
|
||||||
// { href: '/teams', label: 'Teams', icon: 'teams' },
|
{ href: '/teams', label: 'Teams', icon: 'teams' },
|
||||||
{ href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' },
|
{ href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' },
|
||||||
] as item (item.href)}
|
] as item (item.href)}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { user, playerRating, teamMember, team } from '$lib/server/db/schema';
|
import { user, playerRating, teamMember, team, matchInvite } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { setDMHandler, sendDM, getClient } from './client';
|
import { setDMHandler, sendDM, getClient } from './client';
|
||||||
import { joinQueue, leaveQueue, getQueueStatus } from '../match/engine';
|
import { joinQueue, leaveQueue, getQueueStatus } from '../match/engine';
|
||||||
|
import { acceptInvite, declineInvite, getInvitesForUser } from '../match/invites';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
@@ -60,6 +61,15 @@ export async function initDMHandler() {
|
|||||||
case '!status':
|
case '!status':
|
||||||
await handleStatus(ircUsername);
|
await handleStatus(ircUsername);
|
||||||
break;
|
break;
|
||||||
|
case '!invites':
|
||||||
|
await handleInvites(ircUsername);
|
||||||
|
break;
|
||||||
|
case '!accept':
|
||||||
|
await handleAccept(ircUsername, args[0]);
|
||||||
|
break;
|
||||||
|
case '!decline':
|
||||||
|
await handleDecline(ircUsername, args[0]);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
await sendDM(ircUsername, `Unknown command: ${command}. Type !help for available commands.`);
|
await sendDM(ircUsername, `Unknown command: ${command}. Type !help for available commands.`);
|
||||||
}
|
}
|
||||||
@@ -100,6 +110,9 @@ async function handleHelp(ircUsername: string) {
|
|||||||
'!leave — Leave queue | ' +
|
'!leave — Leave queue | ' +
|
||||||
'!elo — Check your rating | ' +
|
'!elo — Check your rating | ' +
|
||||||
'!status — Queue status | ' +
|
'!status — Queue status | ' +
|
||||||
|
'!invites — Pending invites | ' +
|
||||||
|
'!accept <id> — Accept invite | ' +
|
||||||
|
'!decline <id> — Decline invite | ' +
|
||||||
'!help — This message'
|
'!help — This message'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,3 +196,60 @@ async function handleStatus(ircUsername: string) {
|
|||||||
await sendDM(ircUsername, `Not in queue. Type !queue to find a match.`);
|
await sendDM(ircUsername, `Not in queue. Type !queue to find a match.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleInvites(ircUsername: string) {
|
||||||
|
const u = await resolveUser(ircUsername);
|
||||||
|
const { received } = await getInvitesForUser(u.id);
|
||||||
|
const pending = received.filter((i) => i.status === 'pending');
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
await sendDM(ircUsername, 'No pending invites.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = pending.map((i) => {
|
||||||
|
const config = i.config as any;
|
||||||
|
const shortId = i.id.slice(0, 8);
|
||||||
|
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>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccept(ircUsername: string, inviteIdPrefix?: string) {
|
||||||
|
if (!inviteIdPrefix) {
|
||||||
|
await sendDM(ircUsername, 'Usage: !accept <invite-id>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = await resolveUser(ircUsername);
|
||||||
|
const { received } = await getInvitesForUser(u.id);
|
||||||
|
const invite = received.find((i) => i.id.startsWith(inviteIdPrefix) && i.status === 'pending');
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
await sendDM(ircUsername, `No pending invite found matching "${inviteIdPrefix}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecline(ircUsername: string, inviteIdPrefix?: string) {
|
||||||
|
if (!inviteIdPrefix) {
|
||||||
|
await sendDM(ircUsername, 'Usage: !decline <invite-id>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = await resolveUser(ircUsername);
|
||||||
|
const { received } = await getInvitesForUser(u.id);
|
||||||
|
const invite = received.find((i) => i.id.startsWith(inviteIdPrefix) && i.status === 'pending');
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
await sendDM(ircUsername, `No pending invite found matching "${inviteIdPrefix}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await declineInvite(invite.id, u.id);
|
||||||
|
await sendDM(ircUsername, 'Invite declined.');
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,44 @@ export const playerRating = pgTable('player_rating', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Relations ───────────────────────────────────────────────────────────
|
// ── Relations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const teamRelations = relations(team, ({ many }) => ({
|
export const teamRelations = relations(team, ({ many }) => ({
|
||||||
@@ -254,3 +292,12 @@ export const matchGameScoreRelations = relations(matchGameScore, ({ one }) => ({
|
|||||||
export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
|
export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
|
||||||
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
|
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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' }),
|
||||||
|
mappool: one(mappool, { fields: [matchInvite.mappoolId], references: [mappool.id] }),
|
||||||
|
match: one(match, { fields: [matchInvite.matchId], references: [match.id] })
|
||||||
|
}));
|
||||||
|
|||||||
@@ -549,7 +549,9 @@ export async function submitGameScores(
|
|||||||
})
|
})
|
||||||
.where(eq(match.id, game.matchId));
|
.where(eq(match.id, game.matchId));
|
||||||
|
|
||||||
await updateElo(participants, matchWinner.id);
|
if (config.allowEloChange !== false) {
|
||||||
|
await updateElo(participants, matchWinner.id);
|
||||||
|
}
|
||||||
|
|
||||||
const finishedMatch = await getMatchFull(game.matchId);
|
const finishedMatch = await getMatchFull(game.matchId);
|
||||||
notifyMatchFinished(finishedMatch).catch(() => {});
|
notifyMatchFinished(finishedMatch).catch(() => {});
|
||||||
|
|||||||
246
src/lib/server/match/invites.ts
Normal file
246
src/lib/server/match/invites.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { matchInvite, teamMember, notification } from '$lib/server/db/schema';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
|
import { eq, and, or, lt, inArray, desc } from 'drizzle-orm';
|
||||||
|
import { createNotification, markActionedByReference } from '$lib/server/notifications';
|
||||||
|
import { createMatch } from './engine';
|
||||||
|
import type { MatchConfig } from './types';
|
||||||
|
import { sendDM } from '$lib/server/bancho/client';
|
||||||
|
|
||||||
|
const DEFAULT_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
export async function createInvite(opts: {
|
||||||
|
createdBy: string;
|
||||||
|
creatorTeamId: string;
|
||||||
|
invitedTeamId: string;
|
||||||
|
config: MatchConfig;
|
||||||
|
mappoolId: string;
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
scheduledAt?: Date | null;
|
||||||
|
}) {
|
||||||
|
const { createdBy, creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = opts;
|
||||||
|
|
||||||
|
if (creatorTeamId === invitedTeamId && !dev) {
|
||||||
|
throw new Error('Cannot invite your own team');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is on creator team
|
||||||
|
const membership = await db.query.teamMember.findFirst({
|
||||||
|
where: and(eq(teamMember.userId, createdBy), eq(teamMember.teamId, creatorTeamId))
|
||||||
|
});
|
||||||
|
if (!membership) throw new Error('You are not a member of the selected team');
|
||||||
|
|
||||||
|
// Verify invited team exists and has members
|
||||||
|
const invitedMembers = await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.teamId, invitedTeamId)
|
||||||
|
});
|
||||||
|
if (invitedMembers.length === 0) throw new Error('Invited team has no members');
|
||||||
|
|
||||||
|
const baseTime = scheduledAt ?? new Date();
|
||||||
|
const expiresAt = new Date(baseTime.getTime() + DEFAULT_EXPIRY_MS);
|
||||||
|
|
||||||
|
const [invite] = await db
|
||||||
|
.insert(matchInvite)
|
||||||
|
.values({
|
||||||
|
createdBy,
|
||||||
|
creatorTeamId,
|
||||||
|
invitedTeamId,
|
||||||
|
config,
|
||||||
|
mappoolId,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
scheduledAt,
|
||||||
|
expiresAt,
|
||||||
|
status: 'pending'
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Create notification for each invited team member
|
||||||
|
const configSummary = `BO${config.bestOf} ${config.teamSizes ? config.teamSizes.join('v') : `${config.teamSize}v${config.teamSize}`} ${config.scoringType}`;
|
||||||
|
const title = `Match invite: ${name || configSummary}`;
|
||||||
|
const notifMessage = message || `You've been challenged to a ${configSummary} match`;
|
||||||
|
|
||||||
|
for (const member of invitedMembers) {
|
||||||
|
await createNotification(member.userId, 'match_invite', title, notifMessage, invite.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Bancho DMs (best-effort)
|
||||||
|
for (const member of invitedMembers) {
|
||||||
|
try {
|
||||||
|
const u = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, member.userId)
|
||||||
|
});
|
||||||
|
if (u?.name) {
|
||||||
|
await sendDM(
|
||||||
|
u.name,
|
||||||
|
`Match invite: ${title}. Accept/decline at https://esports.vash.software/matches or reply !accept ${invite.id.slice(0, 8)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DM sending is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptInvite(inviteId: string, userId: string) {
|
||||||
|
await expireStaleInvites();
|
||||||
|
|
||||||
|
const invite = await db.query.matchInvite.findFirst({
|
||||||
|
where: eq(matchInvite.id, inviteId),
|
||||||
|
with: { creatorTeam: true, invitedTeam: true, mappool: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) throw new Error('Invite not found');
|
||||||
|
if (invite.status !== 'pending') throw new Error(`Invite is ${invite.status}`);
|
||||||
|
|
||||||
|
// Verify user is on invited team
|
||||||
|
const membership = await db.query.teamMember.findFirst({
|
||||||
|
where: and(eq(teamMember.userId, userId), eq(teamMember.teamId, invite.invitedTeamId))
|
||||||
|
});
|
||||||
|
if (!membership) throw new Error('You are not on the invited team');
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (new Date() > invite.expiresAt) {
|
||||||
|
await db.update(matchInvite).set({ status: 'expired' }).where(eq(matchInvite.id, inviteId));
|
||||||
|
throw new Error('Invite has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = invite.config as MatchConfig;
|
||||||
|
|
||||||
|
// Create the match
|
||||||
|
const created = await createMatch({
|
||||||
|
name: invite.name || `Custom: ${invite.creatorTeam.name} vs ${invite.invitedTeam.name}`,
|
||||||
|
config,
|
||||||
|
mappoolId: invite.mappoolId,
|
||||||
|
teams: [invite.creatorTeamId, invite.invitedTeamId],
|
||||||
|
createdBy: invite.createdBy
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update invite
|
||||||
|
await db
|
||||||
|
.update(matchInvite)
|
||||||
|
.set({ status: 'accepted', matchId: created.id, respondedAt: new Date() })
|
||||||
|
.where(eq(matchInvite.id, inviteId));
|
||||||
|
|
||||||
|
// Mark notifications as actioned for all invited team members
|
||||||
|
const invitedMembers = await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.teamId, invite.invitedTeamId)
|
||||||
|
});
|
||||||
|
for (const member of invitedMembers) {
|
||||||
|
await markActionedByReference(inviteId, member.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify creator
|
||||||
|
await createNotification(
|
||||||
|
invite.createdBy,
|
||||||
|
'invite_accepted',
|
||||||
|
`${invite.invitedTeam.name} accepted your match invite`,
|
||||||
|
undefined,
|
||||||
|
created.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start lobby if immediate (no scheduledAt)
|
||||||
|
if (!invite.scheduledAt) {
|
||||||
|
try {
|
||||||
|
const { initMatchLobby } = await import('./orchestrator');
|
||||||
|
initMatchLobby(created.id).catch((err) => {
|
||||||
|
console.error('[Invites] IRC lobby creation failed:', err.message);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Invites] Failed to import orchestrator:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function declineInvite(inviteId: string, userId: string) {
|
||||||
|
const invite = await db.query.matchInvite.findFirst({
|
||||||
|
where: eq(matchInvite.id, inviteId),
|
||||||
|
with: { invitedTeam: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) throw new Error('Invite not found');
|
||||||
|
if (invite.status !== 'pending') throw new Error(`Invite is ${invite.status}`);
|
||||||
|
|
||||||
|
const membership = await db.query.teamMember.findFirst({
|
||||||
|
where: and(eq(teamMember.userId, userId), eq(teamMember.teamId, invite.invitedTeamId))
|
||||||
|
});
|
||||||
|
if (!membership) throw new Error('You are not on the invited team');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(matchInvite)
|
||||||
|
.set({ status: 'declined', respondedAt: new Date() })
|
||||||
|
.where(eq(matchInvite.id, inviteId));
|
||||||
|
|
||||||
|
// Mark notifications as actioned
|
||||||
|
const invitedMembers = await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.teamId, invite.invitedTeamId)
|
||||||
|
});
|
||||||
|
for (const member of invitedMembers) {
|
||||||
|
await markActionedByReference(inviteId, member.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify creator
|
||||||
|
await createNotification(
|
||||||
|
invite.createdBy,
|
||||||
|
'invite_declined',
|
||||||
|
`${invite.invitedTeam.name} declined your match invite`,
|
||||||
|
undefined,
|
||||||
|
inviteId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelInvite(inviteId: string, userId: string) {
|
||||||
|
const invite = await db.query.matchInvite.findFirst({
|
||||||
|
where: eq(matchInvite.id, inviteId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) throw new Error('Invite not found');
|
||||||
|
if (invite.status !== 'pending') throw new Error(`Invite is ${invite.status}`);
|
||||||
|
if (invite.createdBy !== userId) throw new Error('Only the creator can cancel an invite');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(matchInvite)
|
||||||
|
.set({ status: 'cancelled', respondedAt: new Date() })
|
||||||
|
.where(eq(matchInvite.id, inviteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvitesForUser(userId: string) {
|
||||||
|
await expireStaleInvites();
|
||||||
|
|
||||||
|
// Find all teams the user belongs to
|
||||||
|
const memberships = await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.userId, userId)
|
||||||
|
});
|
||||||
|
const teamIds = memberships.map((m) => m.teamId);
|
||||||
|
if (teamIds.length === 0) return { sent: [], received: [] };
|
||||||
|
|
||||||
|
const sent = await db.query.matchInvite.findMany({
|
||||||
|
where: eq(matchInvite.createdBy, userId),
|
||||||
|
with: { creatorTeam: true, invitedTeam: true, mappool: true },
|
||||||
|
orderBy: desc(matchInvite.createdAt),
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
const received = await db.query.matchInvite.findMany({
|
||||||
|
where: inArray(matchInvite.invitedTeamId, teamIds),
|
||||||
|
with: { creatorTeam: true, invitedTeam: true, mappool: true },
|
||||||
|
orderBy: desc(matchInvite.createdAt),
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sent, received };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expireStaleInvites() {
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.update(matchInvite)
|
||||||
|
.set({ status: 'expired' })
|
||||||
|
.where(and(eq(matchInvite.status, 'pending'), lt(matchInvite.expiresAt, now)));
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
export interface MatchConfig {
|
export interface MatchConfig {
|
||||||
bestOf: number;
|
bestOf: number;
|
||||||
teamSize: number;
|
teamSize: number;
|
||||||
|
teamSizes?: number[];
|
||||||
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
|
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
|
||||||
|
warmups?: number;
|
||||||
|
bans?: number;
|
||||||
|
freemod?: boolean;
|
||||||
|
forceNoFail?: boolean;
|
||||||
|
allowEloChange?: boolean;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MATCH_STATES = {
|
export const MATCH_STATES = {
|
||||||
|
|||||||
67
src/lib/server/notifications.ts
Normal file
67
src/lib/server/notifications.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { notification } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, sql, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function createNotification(
|
||||||
|
userId: string,
|
||||||
|
type: string,
|
||||||
|
title: string,
|
||||||
|
message?: string,
|
||||||
|
referenceId?: string
|
||||||
|
) {
|
||||||
|
const [n] = await db
|
||||||
|
.insert(notification)
|
||||||
|
.values({ userId, type, title, message, referenceId })
|
||||||
|
.returning();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(userId: string): Promise<number> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(notification)
|
||||||
|
.where(and(eq(notification.userId, userId), eq(notification.read, false)));
|
||||||
|
return Number(row.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotifications(userId: string, opts?: { limit?: number; offset?: number }) {
|
||||||
|
return db.query.notification.findMany({
|
||||||
|
where: eq(notification.userId, userId),
|
||||||
|
orderBy: desc(notification.createdAt),
|
||||||
|
limit: opts?.limit ?? 50,
|
||||||
|
offset: opts?.offset ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markRead(notificationId: string, userId: string) {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ read: true })
|
||||||
|
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllRead(userId: string) {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ read: true })
|
||||||
|
.where(and(eq(notification.userId, userId), eq(notification.read, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markActioned(notificationId: string, userId: string) {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ read: true, actionedAt: new Date() })
|
||||||
|
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markActionedByReference(referenceId: string, userId: string) {
|
||||||
|
await db
|
||||||
|
.update(notification)
|
||||||
|
.set({ read: true, actionedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(notification.referenceId, referenceId),
|
||||||
|
eq(notification.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,25 +4,31 @@ import { isRootAdmin } from '$lib/server/permissions';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match, matchParticipant, matchParticipantPlayer } from '$lib/server/db/schema';
|
import { match, matchParticipant, matchParticipantPlayer } from '$lib/server/db/schema';
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
|
import { getUnreadCount } from '$lib/server/notifications';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
let activeMatch: { id: string; name: string | null; state: string } | null = null;
|
let activeMatch: { id: string; name: string | null; state: string } | null = null;
|
||||||
|
let unreadNotificationCount = 0;
|
||||||
|
|
||||||
if (locals.user) {
|
if (locals.user) {
|
||||||
const rows = await db
|
const [rows, notifCount] = await Promise.all([
|
||||||
.select({ id: match.id, name: match.name, state: match.state })
|
db
|
||||||
.from(matchParticipantPlayer)
|
.select({ id: match.id, name: match.name, state: match.state })
|
||||||
.innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id))
|
.from(matchParticipantPlayer)
|
||||||
.innerJoin(match, eq(matchParticipant.matchId, match.id))
|
.innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id))
|
||||||
.where(
|
.innerJoin(match, eq(matchParticipant.matchId, match.id))
|
||||||
and(
|
.where(
|
||||||
eq(matchParticipantPlayer.userId, locals.user.id),
|
and(
|
||||||
inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
eq(matchParticipantPlayer.userId, locals.user.id),
|
||||||
|
inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
getUnreadCount(locals.user.id)
|
||||||
|
]);
|
||||||
|
|
||||||
activeMatch = rows[0] ?? null;
|
activeMatch = rows[0] ?? null;
|
||||||
|
unreadNotificationCount = notifCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +42,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
isRootAdmin: isRootAdmin(locals.user.email)
|
isRootAdmin: isRootAdmin(locals.user.email)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
activeMatch
|
activeMatch,
|
||||||
|
unreadNotificationCount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +1,41 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match } from '$lib/server/db/schema';
|
import { match, teamMember } from '$lib/server/db/schema';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { desc } from 'drizzle-orm';
|
import { desc, eq } from 'drizzle-orm';
|
||||||
import { createMatch } from '$lib/server/match/engine';
|
import { createMatch } from '$lib/server/match/engine';
|
||||||
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||||
|
import { createInvite } from '$lib/server/match/invites';
|
||||||
import { requireAuth, requireRole, hasRole } from '$lib/server/permissions';
|
import { requireAuth, requireRole, hasRole } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
requireAuth(locals);
|
requireAuth(locals);
|
||||||
|
|
||||||
const matches = await db.query.match.findMany({
|
const [matches, teams, mappools, userTeamMemberships] = await Promise.all([
|
||||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
db.query.match.findMany({
|
||||||
orderBy: desc(match.createdAt),
|
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||||
limit: 50
|
orderBy: desc(match.createdAt),
|
||||||
});
|
limit: 50
|
||||||
|
}),
|
||||||
const teams = await db.query.team.findMany({
|
db.query.team.findMany({
|
||||||
orderBy: (t, { asc }) => [asc(t.name)]
|
with: { members: true },
|
||||||
});
|
orderBy: (t, { asc }) => [asc(t.name)]
|
||||||
|
}),
|
||||||
const mappools = await db.query.mappool.findMany({
|
db.query.mappool.findMany({
|
||||||
with: { slots: true },
|
with: { slots: true },
|
||||||
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||||
});
|
}),
|
||||||
|
db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.userId, locals.user!.id),
|
||||||
|
with: { team: true }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matches,
|
matches,
|
||||||
teams,
|
teams: teams.map((t) => ({ ...t, memberCount: t.members.length })),
|
||||||
mappools,
|
mappools,
|
||||||
|
userTeams: userTeamMemberships.map((m) => m.team),
|
||||||
canCreateMatch: hasRole(locals.user!.role, 'referee')
|
canCreateMatch: hasRole(locals.user!.role, 'referee')
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -72,5 +79,52 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirect(303, `/matches/${result.id}`);
|
redirect(303, `/matches/${result.id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createInvite: async ({ request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const creatorTeamId = form.get('creatorTeamId')?.toString();
|
||||||
|
const invitedTeamId = form.get('invitedTeamId')?.toString();
|
||||||
|
const mappoolId = form.get('mappool')?.toString();
|
||||||
|
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 allowEloChange = form.get('allowEloChange') === 'on';
|
||||||
|
const message = form.get('message')?.toString()?.trim() || '';
|
||||||
|
const scheduledAtStr = form.get('scheduledAt')?.toString();
|
||||||
|
|
||||||
|
if (!creatorTeamId || !invitedTeamId || !mappoolId) {
|
||||||
|
return { error: 'All fields are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate bestOf is odd and >= 1
|
||||||
|
if (bestOf < 1 || bestOf % 2 === 0) {
|
||||||
|
return { error: 'Best of must be an odd number >= 1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createInvite({
|
||||||
|
createdBy: locals.user!.id,
|
||||||
|
creatorTeamId,
|
||||||
|
invitedTeamId,
|
||||||
|
config: {
|
||||||
|
bestOf,
|
||||||
|
teamSize: Math.max(teamSize1, teamSize2),
|
||||||
|
teamSizes: [teamSize1, teamSize2],
|
||||||
|
scoringType,
|
||||||
|
allowEloChange
|
||||||
|
},
|
||||||
|
mappoolId,
|
||||||
|
message: message || undefined,
|
||||||
|
scheduledAt: scheduledAtStr ? new Date(scheduledAtStr) : null
|
||||||
|
});
|
||||||
|
return { inviteSuccess: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Matches] Invite error:', e);
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let showCreate = $state(false);
|
let showCreate = $state(false);
|
||||||
|
let showInvite = $state(false);
|
||||||
let createError = $state('');
|
let createError = $state('');
|
||||||
|
let inviteError = $state('');
|
||||||
|
let inviteSuccess = $state(false);
|
||||||
|
|
||||||
|
// Challenge form state
|
||||||
|
let selectedCreatorTeamId = $state('');
|
||||||
|
let selectedInvitedTeamId = $state('');
|
||||||
|
let bestOf = $state(5);
|
||||||
|
let customBestOf = $state('');
|
||||||
|
let useCustomBo = $state(false);
|
||||||
|
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 hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
|
||||||
|
|
||||||
|
// Auto-select personal team if no other teams
|
||||||
|
$effect(() => {
|
||||||
|
if (!hasMultipleTeams && personalTeam) {
|
||||||
|
selectedCreatorTeamId = personalTeam.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCreatorTeam = $derived(data.teams.find((t: any) => t.id === selectedCreatorTeamId));
|
||||||
|
const selectedInvitedTeam = $derived(data.teams.find((t: any) => t.id === selectedInvitedTeamId));
|
||||||
|
const creatorIsPersonal = $derived(selectedCreatorTeam?.isPersonal ?? true);
|
||||||
|
|
||||||
|
const creatorMaxSize = $derived(selectedCreatorTeam?.memberCount ?? 1);
|
||||||
|
const invitedMaxSize = $derived(selectedInvitedTeam?.memberCount ?? 1);
|
||||||
|
|
||||||
|
const effectiveBestOf = $derived(useCustomBo ? parseInt(customBestOf) || 5 : bestOf);
|
||||||
|
|
||||||
|
// Filter opponent teams for search
|
||||||
|
const filteredOpponentTeams = $derived(
|
||||||
|
data.teams.filter((t: any) => {
|
||||||
|
if (t.id === selectedCreatorTeamId) return false;
|
||||||
|
if (!opponentSearch) return true;
|
||||||
|
return t.name.toLowerCase().includes(opponentSearch.toLowerCase());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function teamDisplayName(t: any): string {
|
||||||
|
return t.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mappoolSummary(p: any): string {
|
||||||
|
const slots = p.slots ?? [];
|
||||||
|
if (!slots.length) return '0 maps';
|
||||||
|
const avg = slots.reduce((s: number, sl: any) => s + (sl.starRating ?? 0), 0) / slots.length;
|
||||||
|
// Count per category
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const sl of slots) {
|
||||||
|
counts[sl.category] = (counts[sl.category] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
const parts = Object.entries(counts).map(([cat, n]) => `${n}${cat}`);
|
||||||
|
return `${avg.toFixed(1)}★ · ${parts.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
|
const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
|
||||||
CREATED: { label: 'Created', color: 'text-text-secondary border-border' },
|
CREATED: { label: 'Created', color: 'text-text-secondary border-border' },
|
||||||
@@ -80,14 +138,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if data.canCreateMatch}
|
<div class="flex gap-2">
|
||||||
<button
|
{#if (data as any).userTeams?.length > 0}
|
||||||
onclick={() => (showCreate = !showCreate)}
|
<button
|
||||||
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
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"
|
||||||
{showCreate ? 'Cancel' : 'New Match'}
|
>
|
||||||
</button>
|
{showInvite ? 'Cancel' : 'Challenge'}
|
||||||
{/if}
|
</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"
|
||||||
|
>
|
||||||
|
{showCreate ? 'Cancel' : 'New Match'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Match Modal -->
|
<!-- Create Match Modal -->
|
||||||
@@ -151,7 +219,7 @@
|
|||||||
>
|
>
|
||||||
<option value="">Select team...</option>
|
<option value="">Select team...</option>
|
||||||
{#each data.teams as t}
|
{#each data.teams as t}
|
||||||
<option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
|
<option value={t.id}>{t.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +234,7 @@
|
|||||||
>
|
>
|
||||||
<option value="">Select team...</option>
|
<option value="">Select team...</option>
|
||||||
{#each data.teams as t}
|
{#each data.teams as t}
|
||||||
<option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
|
<option value={t.id}>{t.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,6 +266,201 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Challenge / Invite Form -->
|
||||||
|
{#if showInvite}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/createInvite"
|
||||||
|
use:enhance={() => {
|
||||||
|
inviteError = '';
|
||||||
|
inviteSuccess = false;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-600">Challenge</h2>
|
||||||
|
|
||||||
|
{#if inviteError}
|
||||||
|
<p class="mt-2 text-sm text-red-400">{inviteError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if inviteSuccess}
|
||||||
|
<p class="mt-2 text-sm text-green-400">Invite sent!</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input type="hidden" name="bestOf" value={effectiveBestOf} />
|
||||||
|
<input type="hidden" name="creatorTeamId" value={selectedCreatorTeamId} />
|
||||||
|
<input type="hidden" name="invitedTeamId" value={selectedInvitedTeamId} />
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-4">
|
||||||
|
<!-- 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">
|
||||||
|
{#if personalTeam}
|
||||||
|
<option value={personalTeam.id}>{teamDisplayName(personalTeam)}</option>
|
||||||
|
{/if}
|
||||||
|
{#each nonPersonalUserTeams as t}
|
||||||
|
<option value={t.id}>{t.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Opponent (searchable) -->
|
||||||
|
<div>
|
||||||
|
<label for="inv-opponent-search" class="text-xs font-500 text-text-secondary">Opponent</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="inv-opponent-search"
|
||||||
|
bind:value={opponentSearch}
|
||||||
|
placeholder="Search players or teams..."
|
||||||
|
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"
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{:else if opponentSearch}
|
||||||
|
<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 = ''; }}
|
||||||
|
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}
|
||||||
|
<img src={t.avatarUrl} alt="" class="h-5 w-5 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
<span class="font-500">{teamDisplayName(t)}</span>
|
||||||
|
{#if !t.isPersonal}
|
||||||
|
<span class="text-xs text-text-secondary">({t.memberCount}p)</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="px-3 py-2 text-xs text-text-secondary">No results</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Best Of -->
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-500 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>
|
||||||
|
{/each}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="2"
|
||||||
|
placeholder="Custom"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<!-- 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">
|
||||||
|
{#each Array.from({ length: creatorMaxSize }, (_, i) => i + 1) as n}
|
||||||
|
<option value={n} selected={n === creatorMaxSize}>{n}v</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="teamSize1" value="1" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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">
|
||||||
|
{#each Array.from({ length: invitedMaxSize }, (_, i) => i + 1) as n}
|
||||||
|
<option value={n} selected={n === invitedMaxSize}>v{n}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="teamSize2" value="1" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<option value="score_v2" selected>ScoreV2</option>
|
||||||
|
<option value="score">Score</option>
|
||||||
|
<option value="accuracy">Accuracy</option>
|
||||||
|
<option value="combo">Combo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<option value="">Select mappool...</option>
|
||||||
|
{#each data.mappools as p}
|
||||||
|
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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" />
|
||||||
|
Affects ELO
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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" />
|
||||||
|
</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" />
|
||||||
|
</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">
|
||||||
|
Send Challenge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Live Matches -->
|
<!-- Live Matches -->
|
||||||
{#if liveMatches.length > 0}
|
{#if liveMatches.length > 0}
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
|
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
|
||||||
);
|
);
|
||||||
const otherTeams = $derived(
|
const otherTeams = $derived(
|
||||||
data.teams.filter((t: any) => !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);
|
||||||
|
|
||||||
function isOwner(t: any) {
|
function isOwner(t: any) {
|
||||||
return t.ownerId === data.userId;
|
return t.ownerId === data.userId;
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
|
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
|
||||||
<p class="mt-1 text-sm text-text-secondary">
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
{data.teams.length} team{data.teams.length !== 1 ? 's' : ''}
|
{teamCount} team{teamCount !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-600">{t.name}</span>
|
<a href="/teams/{t.id}" onclick={(e: MouseEvent) => e.stopPropagation()} class="text-sm font-600 hover:text-accent hover:underline">{t.name}</a>
|
||||||
{#if t.isPersonal}
|
{#if t.isPersonal}
|
||||||
<span
|
<span
|
||||||
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary"
|
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary"
|
||||||
@@ -301,7 +302,7 @@
|
|||||||
<h2 class="text-sm font-600 text-text-secondary">Other Teams</h2>
|
<h2 class="text-sm font-600 text-text-secondary">Other Teams</h2>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
{#each otherTeams as t}
|
{#each otherTeams as t}
|
||||||
<div class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3">
|
<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}
|
{#if t.avatarUrl}
|
||||||
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -317,7 +318,7 @@
|
|||||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
56
src/routes/(app)/teams/[id]/+page.server.ts
Normal file
56
src/routes/(app)/teams/[id]/+page.server.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { team, teamMember, match, matchParticipant } from '$lib/server/db/schema';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
|
||||||
|
const t = await db.query.team.findFirst({
|
||||||
|
where: eq(team.id, params.id),
|
||||||
|
with: { members: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!t) error(404, 'Team not found');
|
||||||
|
|
||||||
|
// Fetch member user details
|
||||||
|
const membersWithUsers = await Promise.all(
|
||||||
|
t.members.map(async (m) => {
|
||||||
|
const usr = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, m.userId)
|
||||||
|
});
|
||||||
|
return { ...m, user: usr ? { id: usr.id, name: usr.name, image: usr.image } : null };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch recent matches for this team
|
||||||
|
const participants = await db.query.matchParticipant.findMany({
|
||||||
|
where: eq(matchParticipant.teamId, params.id),
|
||||||
|
with: {
|
||||||
|
match: {
|
||||||
|
with: {
|
||||||
|
participants: { with: { team: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentMatches = participants
|
||||||
|
.map((p) => p.match)
|
||||||
|
.filter((m) => m.state === 'FINISHED' || m.state === 'CANCELLED')
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
team: { ...t, members: membersWithUsers },
|
||||||
|
recentMatches,
|
||||||
|
wins,
|
||||||
|
losses
|
||||||
|
};
|
||||||
|
};
|
||||||
94
src/routes/(app)/teams/[id]/+page.svelte
Normal file
94
src/routes/(app)/teams/[id]/+page.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const t = $derived(data.team);
|
||||||
|
|
||||||
|
function timeAgo(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t.name} — Vash Esports</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#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">
|
||||||
|
{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>
|
||||||
|
{#if t.isPersonal}
|
||||||
|
<span class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary">solo</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-sm text-text-secondary">
|
||||||
|
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||||
|
· {data.wins}W {data.losses}L
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Players -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-sm font-600 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">
|
||||||
|
{#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="text-xs text-text-secondary">{member.role}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Matches -->
|
||||||
|
{#if data.recentMatches.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-sm font-600 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]}
|
||||||
|
{@const p2 = m.participants[1]}
|
||||||
|
{@const won = m.winnerId === t.id}
|
||||||
|
<a
|
||||||
|
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>
|
||||||
|
{#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="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>
|
||||||
|
{: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="text-xs text-text-secondary/50">{timeAgo(m.createdAt)}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
36
src/routes/api/invites/+server.ts
Normal file
36
src/routes/api/invites/+server.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { createInvite, getInvitesForUser } from '$lib/server/match/invites';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
const invites = await getInvitesForUser(locals.user.id);
|
||||||
|
return json(invites);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = body;
|
||||||
|
|
||||||
|
if (!creatorTeamId || !invitedTeamId || !config || !mappoolId) {
|
||||||
|
error(400, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invite = await createInvite({
|
||||||
|
createdBy: locals.user.id,
|
||||||
|
creatorTeamId,
|
||||||
|
invitedTeamId,
|
||||||
|
config,
|
||||||
|
mappoolId,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
scheduledAt: scheduledAt ? new Date(scheduledAt) : null
|
||||||
|
});
|
||||||
|
return json(invite, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
14
src/routes/api/invites/[id]/accept/+server.ts
Normal file
14
src/routes/api/invites/[id]/accept/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { acceptInvite } from '$lib/server/match/invites';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const match = await acceptInvite(params.id, locals.user.id);
|
||||||
|
return json(match);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
14
src/routes/api/invites/[id]/cancel/+server.ts
Normal file
14
src/routes/api/invites/[id]/cancel/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { cancelInvite } from '$lib/server/match/invites';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelInvite(params.id, locals.user.id);
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
14
src/routes/api/invites/[id]/decline/+server.ts
Normal file
14
src/routes/api/invites/[id]/decline/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { declineInvite } from '$lib/server/match/invites';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await declineInvite(params.id, locals.user.id);
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
13
src/routes/api/notifications/+server.ts
Normal file
13
src/routes/api/notifications/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getNotifications } from '$lib/server/notifications';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') ?? '50');
|
||||||
|
const offset = parseInt(url.searchParams.get('offset') ?? '0');
|
||||||
|
|
||||||
|
const notifications = await getNotifications(locals.user.id, { limit, offset });
|
||||||
|
return json(notifications);
|
||||||
|
};
|
||||||
9
src/routes/api/notifications/count/+server.ts
Normal file
9
src/routes/api/notifications/count/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getUnreadCount } from '$lib/server/notifications';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
const count = await getUnreadCount(locals.user.id);
|
||||||
|
return json({ count });
|
||||||
|
};
|
||||||
19
src/routes/api/notifications/read/+server.ts
Normal file
19
src/routes/api/notifications/read/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { markRead, markAllRead } from '$lib/server/notifications';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (body.all) {
|
||||||
|
await markAllRead(locals.user.id);
|
||||||
|
} else if (body.notificationId) {
|
||||||
|
await markRead(body.notificationId, locals.user.id);
|
||||||
|
} else {
|
||||||
|
error(400, 'Provide notificationId or { all: true }');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user