diff --git a/.agents/skills/svelte-code-writer/SKILL.md b/.agents/skills/svelte-code-writer/SKILL.md new file mode 100644 index 0000000..4c1926f --- /dev/null +++ b/.agents/skills/svelte-code-writer/SKILL.md @@ -0,0 +1,66 @@ +--- +name: svelte-code-writer +description: CLI tools for Svelte 5 documentation lookup and code analysis. MUST be used whenever creating, editing or analyzing any Svelte component (.svelte) or Svelte module (.svelte.ts/.svelte.js) +--- + +# Svelte 5 Code Writer + +## CLI Tools + +You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`: + +### List Documentation Sections + +```bash +npx @sveltejs/mcp list-sections +``` + +Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths. + +### Get Documentation + +```bash +npx @sveltejs/mcp get-documentation ",,..." +``` + +Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs. + +**Example:** + +```bash +npx @sveltejs/mcp get-documentation "$state,$derived,$effect" +``` + +### Svelte Autofixer + +```bash +npx @sveltejs/mcp svelte-autofixer "" [options] +``` + +Analyzes Svelte code and suggests fixes for common issues. + +**Options:** + +- `--async` - Enable async Svelte mode (default: false) +- `--svelte-version` - Target version: 4 or 5 (default: 5) + +**Examples:** + +```bash +# Analyze inline code (escape $ as \$) +npx @sveltejs/mcp svelte-autofixer '' + +# Analyze a file +npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte + +# Target Svelte 4 +npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4 +``` + +**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution. + +## Workflow + +1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics +2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues +3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component diff --git a/.agents/skills/svelte-core-bestpractices/SKILL.md b/.agents/skills/svelte-core-bestpractices/SKILL.md new file mode 100644 index 0000000..ffa73ee --- /dev/null +++ b/.agents/skills/svelte-core-bestpractices/SKILL.md @@ -0,0 +1,176 @@ +--- +name: svelte-core-bestpractices +description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more. +--- + +## `$state` + +Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable. + +Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example. + +## `$derived` + +To compute something from state, use `$derived` rather than `$effect`: + +```js +// do this +let square = $derived(num * num); + +// don't do this +let square; + +$effect(() => { + square = num * num; +}); +``` + +> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`. + +Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes. + +If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this. + +## `$effect` + +Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects. + +- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](references/@attach.md) +- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](references/bind.md) as appropriate +- If you need to log values for debugging purposes, use [`$inspect`](references/$inspect.md) +- If you need to observe something external to Svelte, use [`createSubscriber`](references/svelte-reactivity.md) + +Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server. + +## `$props` + +Treat props as though they will change. For example, values that depend on props should usually use `$derived`: + +```js +// @errors: 2451 +let { type } = $props(); + +// do this +let color = $derived(type === 'danger' ? 'red' : 'green'); + +// don't do this — `color` will not update if `type` changes +let color = type === 'danger' ? 'red' : 'green'; +``` + +## `$inspect.trace` + +`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update. + +## Events + +Any element attribute starting with `on` is treated as an event listener: + +```svelte + + + + + + + +``` + +If you need to attach listeners to `window` or `document` you can use `` and ``: + +```svelte + + +``` + +Avoid using `onMount` or `$effect` for this. + +## Snippets + +[Snippets](references/snippet.md) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](references/@render.md) tag, or passed to components as props. They must be declared within the template. + +```svelte +{#snippet greeting(name)} +

hello {name}!

+{/snippet} + +{@render greeting('world')} +``` + +> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside ` + + { + if (onValueChange) { + onValueChange(players.get(playerId) ?? null); + } + }} +> + (searchQuery = evt.currentTarget.value)} + > + + {#if searchQuery.length >= 2} + + {#if loading} +
+ +
+ {:else if items.length === 0} +
+ Inga spelare hittades +
+ {:else} + {#each items as item (item.value)} + + {item.label} + + {/each} + {/if} +
+ {/if} +
diff --git a/src/lib/remote/award.remote.ts b/src/lib/remote/award.remote.ts new file mode 100644 index 0000000..e1dfb75 --- /dev/null +++ b/src/lib/remote/award.remote.ts @@ -0,0 +1,142 @@ +import { command } from '$app/server'; +import { AuthRole } from '$lib/authRole'; +import { db, schema } from '$lib/server/db'; +import { eq } from 'drizzle-orm'; +import z from 'zod'; +import { roleGuard } from './auth.remote'; + +export const createAwardType = command( + z.object({ + name: z.string().min(1), + showDivision: z.boolean() + }), + async ({ name, showDivision }) => { + await roleGuard(AuthRole.MODERATOR); + + const [awardType] = await db + .insert(schema.awardType) + .values({ + name, + showDivision + }) + .returning(); + + return { awardType }; + } +); + +export const updateAwardType = command( + z.object({ + id: z.uuid(), + name: z.string().min(1), + showDivision: z.boolean() + }), + async ({ id, name, showDivision }) => { + await roleGuard(AuthRole.MODERATOR); + + const [awardType] = await db + .update(schema.awardType) + .set({ name, showDivision }) + .where(eq(schema.awardType.id, id)) + .returning(); + + return { awardType }; + } +); + +export const deleteAwardType = command( + z.object({ + id: z.uuid() + }), + async ({ id }) => { + await roleGuard(AuthRole.MODERATOR); + + await db.delete(schema.awardType).where(eq(schema.awardType.id, id)); + } +); + +export const createPlayerAward = command( + z.object({ + awardTypeId: z.uuid(), + playerId: z.uuid(), + divisionId: z.uuid().nullish() + }), + async ({ awardTypeId, playerId, divisionId }) => { + await roleGuard(AuthRole.MODERATOR); + + const [award] = await db + .insert(schema.playerAward) + .values({ + awardTypeId, + playerId, + divisionId: divisionId ?? null + }) + .returning(); + + const hydrated = await db.query.playerAward.findFirst({ + where: { + id: award.id + }, + with: { + player: { + columns: { + id: true, + battletag: true + } + }, + division: { + columns: { + id: true, + name: true, + slug: true + }, + with: { + season: { + columns: { + id: true, + name: true, + slug: true, + startedAt: true + } + } + } + } + } + }); + + return { award: hydrated ?? award }; + } +); + +export const updatePlayerAward = command( + z.object({ + id: z.uuid(), + playerId: z.uuid(), + divisionId: z.uuid().nullish() + }), + async ({ id, playerId, divisionId }) => { + await roleGuard(AuthRole.MODERATOR); + + const [award] = await db + .update(schema.playerAward) + .set({ + playerId, + divisionId: divisionId ?? null + }) + .where(eq(schema.playerAward.id, id)) + .returning(); + + return { award }; + } +); + +export const deletePlayerAward = command( + z.object({ + id: z.uuid() + }), + async ({ id }) => { + await roleGuard(AuthRole.MODERATOR); + + await db.delete(schema.playerAward).where(eq(schema.playerAward.id, id)); + } +); diff --git a/src/lib/remote/player.remote.ts b/src/lib/remote/player.remote.ts index 2894ce1..70e9909 100644 --- a/src/lib/remote/player.remote.ts +++ b/src/lib/remote/player.remote.ts @@ -1,4 +1,4 @@ -import { command, getRequestEvent } from '$app/server'; +import { command, getRequestEvent, query } from '$app/server'; import { AuthRole, canEditUserPage, isModerator } from '$lib/authRole'; import { socialSchema } from '$lib/schemas'; import { db, schema } from '$lib/server/db'; @@ -169,12 +169,12 @@ export const setProfileSlug = command( } ); -export const linkPlayerAlias = command( - z.object({ - playerId: z.uuid(), - otherPlayerId: z.uuid() - }), - async ({ playerId, otherPlayerId }) => { +export const linkPlayerAlias = command( + z.object({ + playerId: z.uuid(), + otherPlayerId: z.uuid() + }), + async ({ playerId, otherPlayerId }) => { await roleGuard(AuthRole.MODERATOR); const otherPlayer = await db.query.player.findFirst({ @@ -190,6 +190,46 @@ export const linkPlayerAlias = command( .set({ playerId, registeredName: otherPlayer.battletag }) .where(eq(schema.member.playerId, otherPlayerId)); - await db.delete(schema.player).where(eq(schema.player.id, otherPlayerId)); - } -); + await db.delete(schema.player).where(eq(schema.player.id, otherPlayerId)); + } +); + +export const queryPlayers = query( + z.object({ + query: z.string() + }), + async ({ query }) => { + await roleGuard(AuthRole.MODERATOR); + + if (query.trim().length < 2) { + return []; + } + + return await db.query.player.findMany({ + limit: 15, + where: { + OR: [ + { + battletag: { + ilike: `%${query}%` + } + }, + { + aliases: { + name: { + ilike: `%${query}%` + } + } + } + ] + }, + columns: { + id: true, + battletag: true + }, + orderBy: { + battletag: 'asc' + } + }); + } +); diff --git a/src/lib/server/db/relations.ts b/src/lib/server/db/relations.ts index 3314fdf..5138eef 100644 --- a/src/lib/server/db/relations.ts +++ b/src/lib/server/db/relations.ts @@ -13,7 +13,8 @@ const relations = defineRelations(schema, (r) => ({ optional: false }), groups: r.many.group(), - brackets: r.many.bracket() + brackets: r.many.bracket(), + awards: r.many.playerAward() }, group: { division: r.one.division({ @@ -49,7 +50,27 @@ const relations = defineRelations(schema, (r) => ({ memberships: r.many.member(), socials: r.many.playerSocial(), aliases: r.many.playerAlias(), - signatureHeroes: r.many.signatureHero() + signatureHeroes: r.many.signatureHero(), + awards: r.many.playerAward() + }, + awardType: { + awards: r.many.playerAward() + }, + playerAward: { + awardType: r.one.awardType({ + from: r.playerAward.awardTypeId, + to: r.awardType.id, + optional: false + }), + player: r.one.player({ + from: r.playerAward.playerId, + to: r.player.id, + optional: false + }), + division: r.one.division({ + from: r.playerAward.divisionId, + to: r.division.id + }) }, member: { player: r.one.player({ diff --git a/src/lib/server/db/schema/tournament.ts b/src/lib/server/db/schema/tournament.ts index 78529f6..6e41352 100644 --- a/src/lib/server/db/schema/tournament.ts +++ b/src/lib/server/db/schema/tournament.ts @@ -127,6 +127,27 @@ export const player = pgTable( (t) => [index('player_battletag_gin_idx').using('gin', t.battletag.op('gin_trgm_ops'))] ); +export const awardType = pgTable( + 'award_type', + { + id: uuid().primaryKey().defaultRandom(), + name: text().notNull(), + showDivision: boolean().notNull().default(true) + }, + (t) => [unique().on(t.name)] +); + +export const playerAward = pgTable('player_award', { + id: uuid().primaryKey().defaultRandom(), + awardTypeId: uuid() + .notNull() + .references(() => awardType.id, { onDelete: 'cascade' }), + playerId: uuid() + .notNull() + .references(() => player.id, { onDelete: 'cascade' }), + divisionId: uuid().references(() => division.id, { onDelete: 'cascade' }) +}); + export const rankEnum = pgEnum('rank', enumToPgEnum(Rank)); export const roleEnum = pgEnum('role', enumToPgEnum(Role)); diff --git a/src/lib/server/db/seed.ts b/src/lib/server/db/seed.ts index 732b775..0e7744b 100644 --- a/src/lib/server/db/seed.ts +++ b/src/lib/server/db/seed.ts @@ -89,6 +89,8 @@ export async function seed(db: PostgresJsDatabase) { const seedSchema = { team: schema.team, player: schema.player, + awardType: schema.awardType, + playerAward: schema.playerAward, roster: schema.roster, member: schema.member, season: schema.season, diff --git a/src/routes/(app)/admin/+page.svelte b/src/routes/(app)/admin/+page.svelte index 3234e0a..f3432e6 100644 --- a/src/routes/(app)/admin/+page.svelte +++ b/src/routes/(app)/admin/+page.svelte @@ -12,7 +12,7 @@ import { createSeason } from '$lib/remote/season.remote'; import Checkbox from '$lib/components/ui/Checkbox.svelte'; import { uploadSeasonData } from '$lib/remote/misc.remote'; - import { isAdmin } from '$lib/authRole.js'; + import { isAdmin, isModerator } from '$lib/authRole.js'; import AdminLinkList from '$lib/components/admin/AdminLinkList.svelte'; let { data } = $props(); @@ -90,6 +90,14 @@ {/if} +{#if isModerator(data.user?.role)} + +
+ Hantera utmärkelser +
+
+{/if} + { + if (!checkPermission(locals.user?.role, AuthRole.MODERATOR)) { + throw error(403, 'Insufficient permissions'); + } + + const awardTypes = await db.query.awardType.findMany({ + orderBy: { + name: 'asc' + }, + with: { + awards: { + columns: { + id: true + } + } + } + }); + + return { + awardTypes: awardTypes.map(({ awards, ...awardType }) => ({ + ...awardType, + awardCount: awards.length + })) + }; +}; diff --git a/src/routes/(app)/admin/utmarkelser/+page.svelte b/src/routes/(app)/admin/utmarkelser/+page.svelte new file mode 100644 index 0000000..2867847 --- /dev/null +++ b/src/routes/(app)/admin/utmarkelser/+page.svelte @@ -0,0 +1,83 @@ + + + + + + {#if awardTypes.length === 0} + (createOpen = true)} + hideCreateButton={!canEdit} + > + Inga utmärkelser har skapats än. + + {:else} +
+ {#each awardTypes as awardType (awardType.id)} + + {awardType.name} + + {awardType.awardCount} st + + + {/each} +
+ + {#if canEdit} +