From 26b6c63caf3be1b2b50ae35dd2d81060c8cbd7a7 Mon Sep 17 00:00:00 2001
From: Mihkel Martin Kasterpalu <qpeuitovxg@use.startmail.com>
Date: Tue, 21 Jan 2025 11:42:52 +0200
Subject: [PATCH] Better styling and loading logic, save state for user session

---
 src/lib/components/DNDGroup.svelte            |  58 +++++---
 .../alert-dialog/alert-dialog-action.svelte   |  13 ++
 .../alert-dialog/alert-dialog-cancel.svelte   |  17 +++
 .../alert-dialog/alert-dialog-content.svelte  |  26 ++++
 .../alert-dialog-description.svelte           |  16 +++
 .../alert-dialog/alert-dialog-footer.svelte   |  20 +++
 .../alert-dialog/alert-dialog-header.svelte   |  20 +++
 .../alert-dialog/alert-dialog-overlay.svelte  |  19 +++
 .../ui/alert-dialog/alert-dialog-title.svelte |  18 +++
 src/lib/components/ui/alert-dialog/index.ts   |  40 ++++++
 .../components/ui/card/card-content.svelte    |  16 +++
 .../ui/card/card-description.svelte           |  16 +++
 src/lib/components/ui/card/card-footer.svelte |  16 +++
 src/lib/components/ui/card/card-header.svelte |  16 +++
 src/lib/components/ui/card/card-title.svelte  |  25 ++++
 src/lib/components/ui/card/card.svelte        |  20 +++
 src/lib/components/ui/card/index.ts           |  22 +++
 src/lib/components/ui/skeleton/index.ts       |   7 +
 .../components/ui/skeleton/skeleton.svelte    |  17 +++
 src/lib/server/PlayerState.svelte.ts          | 110 +++++++++-----
 src/lib/types.ts                              |   1 +
 src/lib/utils.ts                              |  45 ++++++
 src/routes/+layout.svelte                     |   4 +-
 src/routes/+page.server.ts                    | 135 +++++++++++-------
 src/routes/+page.svelte                       |  85 ++++++++++-
 src/routes/api/checkSolve/+server.ts          |  12 ++
 26 files changed, 674 insertions(+), 120 deletions(-)
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
 create mode 100644 src/lib/components/ui/alert-dialog/index.ts
 create mode 100644 src/lib/components/ui/card/card-content.svelte
 create mode 100644 src/lib/components/ui/card/card-description.svelte
 create mode 100644 src/lib/components/ui/card/card-footer.svelte
 create mode 100644 src/lib/components/ui/card/card-header.svelte
 create mode 100644 src/lib/components/ui/card/card-title.svelte
 create mode 100644 src/lib/components/ui/card/card.svelte
 create mode 100644 src/lib/components/ui/card/index.ts
 create mode 100644 src/lib/components/ui/skeleton/index.ts
 create mode 100644 src/lib/components/ui/skeleton/skeleton.svelte
 create mode 100644 src/routes/api/checkSolve/+server.ts

diff --git a/src/lib/components/DNDGroup.svelte b/src/lib/components/DNDGroup.svelte
index e4a4079..2fd8d4e 100644
--- a/src/lib/components/DNDGroup.svelte
+++ b/src/lib/components/DNDGroup.svelte
@@ -1,6 +1,9 @@
 <script lang="ts">
+	import * as Card from '$lib/components/ui/card/index.js';
 	import { dndzone } from 'svelte-dnd-action';
 	import { flip } from 'svelte/animate';
+	import { expoOut } from 'svelte/easing';
+	import { truncate } from '$lib/utils';
 
 	let { items = $bindable(), image = false, type = 'default' } = $props();
 
@@ -15,25 +18,44 @@
 </script>
 
 <section
-	use:dndzone={{ items, flipDurationMs, type: type }}
+	use:dndzone={{
+		items,
+		flipDurationMs,
+		type: type,
+		dropTargetStyle: { outline: 'none' }
+	}}
 	onconsider={handleDndConsider}
 	onfinalize={handleDndFinalize}
-	class="grid grid-cols-3"
+	class="grid grid-cols-3 items-center gap-16"
 >
-	{#if image}
-		{#each items as item, i (item.id)}
-			<div animate:flip={{ duration: flipDurationMs }}>
-				<img class="object-cover" alt="Album Art" src={item.value} />
-				<input type="hidden" name="{type}_{i}" value={item.value} />
-			</div>
-		{/each}
-	{:else}
-		{#each items as item, i (item.id)}
-			<div animate:flip={{ duration: flipDurationMs }}>
-				<p class="text-center">{item.value}</p>
-
-				<input type="hidden" name="{type}_{i}" value={item.value} />
-			</div>
-		{/each}
-	{/if}
+	{#each items as item, i (item.id)}
+		<div animate:flip={{ duration: flipDurationMs, easing: expoOut }}>
+			<Card.Root
+				class="overflow-hidden rounded-xl border bg-card text-card-foreground shadow {type ===
+				'names'
+					? 'border-orange-300'
+					: type === 'artists'
+						? 'border-cyan-300'
+						: ''}"
+			>
+				{#if image}
+					<Card.Content class="p-0">
+						<img class="aspect-square w-full object-cover" alt="Album Art" src={item.value} />
+						<input type="hidden" name="{type}_{i}" value={item.value} />
+					</Card.Content>
+				{:else}
+					<Card.Content>
+						<p class="text-center">
+							{#if type === 'artists'}
+								{truncate(item.value, 30)}
+							{:else}
+								{truncate(item.value, 45)}
+							{/if}
+						</p>
+						<input type="hidden" name="{type}_{i}" value={item.value} />
+					</Card.Content>
+				{/if}
+			</Card.Root>
+		</div>
+	{/each}
 </section>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 0000000..715a24f
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { buttonVariants } from "$lib/components/ui/button/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		class: className,
+		ref = $bindable(null),
+		...restProps
+	}: AlertDialogPrimitive.ActionProps = $props();
+</script>
+
+<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} />
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 0000000..e0226fd
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { buttonVariants } from "$lib/components/ui/button/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		class: className,
+		ref = $bindable(null),
+		...restProps
+	}: AlertDialogPrimitive.CancelProps = $props();
+</script>
+
+<AlertDialogPrimitive.Cancel
+	bind:ref
+	class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
+	{...restProps}
+/>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 0000000..858b8bc
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from "bits-ui";
+	import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		portalProps,
+		...restProps
+	}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
+		portalProps?: AlertDialogPrimitive.PortalProps;
+	} = $props();
+</script>
+
+<AlertDialogPrimitive.Portal {...portalProps}>
+	<AlertDialogOverlay />
+	<AlertDialogPrimitive.Content
+		bind:ref
+		class={cn(
+			"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
+			className
+		)}
+		{...restProps}
+	/>
+</AlertDialogPrimitive.Portal>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 0000000..600ef8c
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		class: className,
+		ref = $bindable(null),
+		...restProps
+	}: AlertDialogPrimitive.DescriptionProps = $props();
+</script>
+
+<AlertDialogPrimitive.Description
+	bind:ref
+	class={cn("text-muted-foreground text-sm", className)}
+	{...restProps}
+/>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 0000000..91ecaba
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 0000000..44a7b08
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	class={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 0000000..62acab8
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		class: className,
+		ref = $bindable(null),
+		...restProps
+	}: AlertDialogPrimitive.OverlayProps = $props();
+</script>
+
+<AlertDialogPrimitive.Overlay
+	bind:ref
+	class={cn(
+		"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
+		className
+	)}
+	{...restProps}
+/>
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 0000000..ef197dc
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+	import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		class: className,
+		level = 3,
+		ref = $bindable(null),
+		...restProps
+	}: AlertDialogPrimitive.TitleProps = $props();
+</script>
+
+<AlertDialogPrimitive.Title
+	bind:ref
+	class={cn("text-lg font-semibold", className)}
+	{level}
+	{...restProps}
+/>
diff --git a/src/lib/components/ui/alert-dialog/index.ts b/src/lib/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..dd20f99
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/index.ts
@@ -0,0 +1,40 @@
+import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
+
+import Title from "./alert-dialog-title.svelte";
+import Action from "./alert-dialog-action.svelte";
+import Cancel from "./alert-dialog-cancel.svelte";
+import Footer from "./alert-dialog-footer.svelte";
+import Header from "./alert-dialog-header.svelte";
+import Overlay from "./alert-dialog-overlay.svelte";
+import Content from "./alert-dialog-content.svelte";
+import Description from "./alert-dialog-description.svelte";
+
+const Root = AlertDialogPrimitive.Root;
+const Trigger = AlertDialogPrimitive.Trigger;
+const Portal = AlertDialogPrimitive.Portal;
+
+export {
+	Root,
+	Title,
+	Action,
+	Cancel,
+	Portal,
+	Footer,
+	Header,
+	Trigger,
+	Overlay,
+	Content,
+	Description,
+	//
+	Root as AlertDialog,
+	Title as AlertDialogTitle,
+	Action as AlertDialogAction,
+	Cancel as AlertDialogCancel,
+	Portal as AlertDialogPortal,
+	Footer as AlertDialogFooter,
+	Header as AlertDialogHeader,
+	Trigger as AlertDialogTrigger,
+	Overlay as AlertDialogOverlay,
+	Content as AlertDialogContent,
+	Description as AlertDialogDescription,
+};
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..1f52856
--- /dev/null
+++ b/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div bind:this={ref} class={cn("p-6", className)} {...restProps}>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..da02664
--- /dev/null
+++ b/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
+</script>
+
+<p bind:this={ref} class={cn("text-muted-foreground text-sm", className)} {...restProps}>
+	{@render children?.()}
+</p>
diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..6894149
--- /dev/null
+++ b/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div bind:this={ref} class={cn("flex items-center p-6 pt-0", className)} {...restProps}>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..1baa92c
--- /dev/null
+++ b/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div bind:this={ref} class={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...restProps}>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..a201620
--- /dev/null
+++ b/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		level = 3,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+		level?: 1 | 2 | 3 | 4 | 5 | 6;
+	} = $props();
+</script>
+
+<div
+	role="heading"
+	aria-level={level}
+	bind:this={ref}
+	class={cn("font-semibold leading-none tracking-tight", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..c7531d5
--- /dev/null
+++ b/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+	import type { WithElementRef } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	class={cn("bg-card text-card-foreground rounded-xl border shadow", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..0f9084d
--- /dev/null
+++ b/src/lib/components/ui/card/index.ts
@@ -0,0 +1,22 @@
+import Root from "./card.svelte";
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+
+export {
+	Root,
+	Content,
+	Description,
+	Footer,
+	Header,
+	Title,
+	//
+	Root as Card,
+	Content as CardContent,
+	Description as CardDescription,
+	Footer as CardFooter,
+	Header as CardHeader,
+	Title as CardTitle,
+};
diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..186db21
--- /dev/null
+++ b/src/lib/components/ui/skeleton/index.ts
@@ -0,0 +1,7 @@
+import Root from "./skeleton.svelte";
+
+export {
+	Root,
+	//
+	Root as Skeleton,
+};
diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte
new file mode 100644
index 0000000..1f8bbc5
--- /dev/null
+++ b/src/lib/components/ui/skeleton/skeleton.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+	import type { WithElementRef, WithoutChildren } from "bits-ui";
+	import type { HTMLAttributes } from "svelte/elements";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
+</script>
+
+<div
+	bind:this={ref}
+	class={cn("bg-primary/10 animate-pulse rounded-md", className)}
+	{...restProps}
+></div>
diff --git a/src/lib/server/PlayerState.svelte.ts b/src/lib/server/PlayerState.svelte.ts
index a8498cd..eb3afa9 100644
--- a/src/lib/server/PlayerState.svelte.ts
+++ b/src/lib/server/PlayerState.svelte.ts
@@ -2,59 +2,91 @@ import type { Player } from '$lib/types';
 import { getContext, setContext } from 'svelte';
 
 export class PlayerState {
-  players = $state<Player[]>([]);
+	players = $state<Player[]>([]);
 
-  newPlayer(id: string) {
-    this.players.push({ id: id, stage: 0, highscore: 0 });
-  }
+	newPlayer(id: string) {
+		this.players.push({ id: id, stage: 0, highscore: 0, playing: true });
+	}
 
-  getStage(id: string) {
-    const player = this.players.find((player) => player.id === id);
+	getStage(id: string) {
+		const player = this.players.find((player) => player.id === id);
 
-    if (!player) {
-      return undefined;
-    }
+		if (!player) {
+			return undefined;
+		}
 
-    return player.stage;
-  }
+		return player.stage;
+	}
 
-  nextStage(id: string) {
-    const player = this.players.find((player) => player.id === id);
+	nextStage(id: string) {
+		const player = this.players.find((player) => player.id === id);
 
-    if (!player) {
-      return;
-    }
+		if (!player) {
+			return;
+		}
 
-    player.stage += 1;
-  }
+		player.stage += 1;
+	}
 
-  score(id: string, win: boolean) {
-    const player = this.players.find((player) => player.id === id);
+	restart(id: string) {
+		const player = this.players.find((player) => player.id === id);
 
-    if (!player) {
-      return;
-    }
+		if (!player) {
+			return;
+		}
 
-    if (win) {
-      player.stage += 1;
+		player.stage = 0;
+		player.playing = true;
+	}
 
-      if (player.stage > player.highscore) {
-        player.highscore = player.stage;
-      }
-    } else {
-      player.stage = 0;
-    }
-  }
+	score(id: string, won: boolean) {
+		const player = this.players.find((player) => player.id === id);
 
-  getHighscore(id: string) {
-    const player = this.players.find((player) => player.id === id);
+		if (!player) {
+			return;
+		}
 
-    if (!player) {
-      return undefined;
-    }
+		if (won) {
+			player.stage += 1;
+			return;
+		}
 
-    return player.highscore;
-  }
+		player.playing = false;
+
+		if (player.stage > player.highscore) {
+			player.highscore = player.stage;
+		}
+	}
+
+	getHighscore(id: string) {
+		const player = this.players.find((player) => player.id === id);
+
+		if (!player) {
+			return undefined;
+		}
+
+		return player.highscore;
+	}
+
+	getPlaying(id: string) {
+		const player = this.players.find((player) => player.id === id);
+
+		if (!player) {
+			return undefined;
+		}
+
+		return player.playing;
+	}
+
+	setPlaying(id: string, playing: boolean) {
+		const player = this.players.find((player) => player.id === id);
+
+		if (!player) {
+			return;
+		}
+
+		player.playing = playing;
+	}
 }
 
 export const playerState = new PlayerState();
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 37d1116..1ef0567 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -8,4 +8,5 @@ export type Player = {
   id: string;
   stage: number;
   highscore: number;
+  playing: boolean;
 };
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 61d5240..5d47c69 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -60,3 +60,48 @@ export function shuffleArray<T>(array: T[]): T[] {
 
 	return array;
 }
+
+export function truncate(text: string, maxLength: number): string {
+	// Return original text if it's shorter than or equal to maxLength
+	if (text.length <= maxLength) {
+		return text;
+	}
+
+	const ellipsis = '…';
+	const tolerance = 5;
+	const targetLength = maxLength - 1; // Account for ellipsis
+
+	// Look for spaces within the tolerance range before targetLength
+	let spaceBeforeIdx = -1;
+	for (let i = targetLength; i >= targetLength - tolerance; i--) {
+		if (text[i] === ' ') {
+			spaceBeforeIdx = i;
+			break;
+		}
+	}
+
+	// Look for spaces within the tolerance range after targetLength
+	let spaceAfterIdx = -1;
+	for (let i = targetLength; i <= targetLength + tolerance && i < text.length; i++) {
+		if (text[i] === ' ') {
+			spaceAfterIdx = i;
+			break;
+		}
+	}
+
+	// Determine the best cutoff point
+	let cutoffIndex = targetLength;
+	if (spaceBeforeIdx !== -1 && spaceAfterIdx !== -1) {
+		// If we found spaces both before and after, use the closest one
+		cutoffIndex =
+			targetLength - spaceBeforeIdx <= spaceAfterIdx - targetLength
+				? spaceBeforeIdx
+				: spaceAfterIdx;
+	} else if (spaceBeforeIdx !== -1) {
+		cutoffIndex = spaceBeforeIdx;
+	} else if (spaceAfterIdx !== -1) {
+		cutoffIndex = spaceAfterIdx;
+	}
+
+	return text.slice(0, cutoffIndex).trim() + ellipsis;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 9b776b7..5c50b44 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -3,4 +3,6 @@
 	let { children } = $props();
 </script>
 
-{@render children()}
+<div class="flex min-h-screen items-center">
+	{@render children()}
+</div>
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
index 3b3d98d..f2c0afc 100644
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -4,80 +4,105 @@ import type { PageServerLoad } from './$types';
 import type { AlbumSolveState } from '$lib/types';
 import { albumState } from '$lib/server/AlbumState.svelte';
 import { playerState } from '$lib/server/PlayerState.svelte';
-import { invalidateAll } from '$app/navigation';
 
 const count = 3;
+
 export const load: PageServerLoad = async ({ fetch, locals }) => {
-  const { session } = locals;
+	const { session } = locals;
 
-  if (!session?.data?.userId) {
-    await session.setData({ userId: nanoid() });
-    await session.save();
-  }
+	if (!session?.data?.userId) {
+		await session.setData({ userId: nanoid() });
+		await session.save();
+	}
 
-  const user = session.data.userId;
-  let stage = playerState.getStage(user);
+	const user = session.data.userId;
+	let stage = playerState.getStage(user);
 
-  if (!stage) {
-    playerState.newPlayer(user);
-    stage = playerState.getStage(user);
-  }
+	if (!stage) {
+		playerState.newPlayer(user);
+		stage = playerState.getStage(user);
+	}
 
-  const highscore = playerState.getHighscore(user);
+	const highscore = playerState.getHighscore(user);
 
-  const albumData = await fetch(`/api/getAlbums/${count}`)
-    .then((res) => {
-      return res.json();
-    })
-    .then((data) => {
-      return data;
-    });
+	if (!playerState.getPlaying(user)) {
+		return {
+			stage: stage,
+			highscore: highscore,
+			playing: false
+		};
+	}
 
-  const albumNames = albumData.albums.map((album) => ({ id: nanoid(), value: album.name }));
-  const albumImages = albumData.albums.map((album) => ({
-    id: nanoid(),
-    value: album.images.at(0).url
-  }));
-  const albumArtists = albumData.albums.map((album) => ({
-    id: nanoid(),
-    value: album.artists.map((artist) => artist.name).join(', ')
-  }));
+	const albumData = fetch(`/api/getAlbums/${count}`)
+		.then((res) => {
+			return res.json();
+		})
+		.then((data) => {
+			const albumNames = data.albums.map((album) => ({ id: nanoid(), value: album.name }));
+			const albumImages = data.albums.map((album) => ({
+				id: nanoid(),
+				value: album.images.at(0).url
+			}));
+			const albumArtists = data.albums.map((album) => ({
+				id: nanoid(),
+				value: album.artists.map((artist) => artist.name).join(', ')
+			}));
 
-  return {
-    names: shuffleArray(albumNames),
-    images: shuffleArray(albumImages),
-    artists: shuffleArray(albumArtists),
-    stage: stage,
-    highscore: highscore
-  };
+			return {
+				names: shuffleArray(albumNames),
+				images: shuffleArray(albumImages),
+				artists: shuffleArray(albumArtists)
+			};
+		});
+
+	return {
+		stage: stage,
+		highscore: highscore,
+		playing: true,
+		streamed: { albums: albumData }
+	};
 };
 
 export const actions = {
-  default: async ({ request, locals }) => {
-    const { session } = locals; // you can access `locals.session`
+	submit: async ({ request, locals }) => {
+		const { session } = locals;
+		if (!session?.data?.userId) {
+			return;
+		}
 
-    if (!session?.data?.userId) {
-      return;
-    }
+		const user = session.data.userId;
 
-    const user = session.data.userId;
-    const data = await request.formData();
+		const data = await request.formData();
 
-    const state: AlbumSolveState[] = [];
+		const state: AlbumSolveState[] = [];
 
-    for (let i = 0; i < count; i++) {
-      const name = data.get(`names_${i}`);
-      const image = data.get(`images_${i}`);
-      const artists = data.get(`artists_${i}`);
+		for (let i = 0; i < count; i++) {
+			const name = data.get(`names_${i}`);
+			const image = data.get(`images_${i}`);
+			const artists = data.get(`artists_${i}`);
 
-      const artistList = artists.split(', ');
+			const artistList = artists.split(', ');
 
-      state.push({ name: name, imageUrl: image, artists: artistList });
-    }
+			state.push({ name: name, imageUrl: image, artists: artistList });
+		}
 
-    const solved = albumState.checkSolve(state);
+		const solved = albumState.checkSolve(state);
 
-    playerState.score(user, solved);
-    return { loading: false };
-  }
+		playerState.score(user, solved);
+
+		return { solved: solved };
+	},
+
+	restart: async ({ locals }) => {
+		const { session } = locals;
+		if (!session?.data?.userId) {
+			return;
+		}
+
+		const user = session.data.userId;
+
+		playerState.restart(user);
+
+		return { solved: undefined };
+	}
 };
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index ef5b526..60fa13b 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,22 +1,93 @@
 <script lang="ts">
 	import { Button } from '$lib/components/ui/button/index.js';
+	import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
+	import { Skeleton } from '$lib/components/ui/skeleton/index.js';
 	import DndGroup from '$lib/components/DNDGroup.svelte';
 	import type { PageData } from './$types';
 	import { enhance } from '$app/forms';
-	import { invalidateAll } from '$app/navigation';
 
-	let { data }: { data: PageData } = $props();
+	let { data, form }: { data: PageData; form: FormData } = $props();
+
+	let loading = $state(true);
+
+	let names: string[] | undefined = $state();
+	let artists: string[] | undefined = $state();
+	let images: string[] | undefined = $state();
+
+	$effect(() => {
+		loading = false;
+		console.log(form);
+	});
+
 	$inspect(data);
 </script>
 
-<form method="POST" use:enhance class="grid gap-8">
-	<DndGroup items={data.names} type="names"></DndGroup>
-	<DndGroup items={data.artists} type="artists"></DndGroup>
-	<DndGroup items={data.images} image type="images"></DndGroup>
+<AlertDialog.Root open={form?.solved === false}>
+	<AlertDialog.Content>
+		<AlertDialog.Header>
+			<AlertDialog.Title>
+				{#if data?.highscore && data?.stage && data.highscore === data.stage}
+					New high score!
+				{:else}
+					Maybe next time
+				{/if}
+			</AlertDialog.Title>
+			<AlertDialog.Description>
+				{#if data.stage === 0}
+					That's tough. <strong>0 right answers.</strong>
+				{:else}
+					You got it right <strong>{data.stage} times.</strong>
+				{/if}
+			</AlertDialog.Description>
+		</AlertDialog.Header>
+		<AlertDialog.Footer>
+			<form action="?/restart" method="POST" use:enhance>
+				<AlertDialog.Action type="submit">Try again</AlertDialog.Action>
+			</form>
+		</AlertDialog.Footer>
+	</AlertDialog.Content>
+</AlertDialog.Root>
+
+<form
+	action="?/submit"
+	method="POST"
+	use:enhance
+	class="mx-auto grid w-full max-w-3xl gap-8 px-8 transition-all {loading || data?.playing === false
+		? 'grayscale'
+		: ''}"
+	onsubmit={() => {
+		loading = true;
+	}}
+>
+	{#await data.streamed.albums}
+		{#each { length: 2 } as _}
+			<section class="grid grid-cols-3 items-center gap-16">
+				{#each { length: 3 } as _}
+					<Skeleton class="h-[6rem] w-full rounded-xl " />
+				{/each}
+			</section>
+		{/each}
+
+		<section class="grid grid-cols-3 items-center gap-16">
+			{#each { length: 3 } as _}
+				<Skeleton class="aspect-square h-auto max-w-full rounded-xl object-cover" />
+			{/each}
+		</section>
+	{:then albums}
+		{#if albums.names && albums.artists && albums.images}
+			<DndGroup items={albums.names} type="names"></DndGroup>
+			<DndGroup items={albums.artists} type="artists"></DndGroup>
+			<DndGroup items={albums.images} image type="images"></DndGroup>
+		{:else}
+			<DndGroup items={names} type="names"></DndGroup>
+			<DndGroup items={artists} type="artists"></DndGroup>
+			<DndGroup items={images} image type="images"></DndGroup>
+		{/if}
+	{/await}
 
 	<div class="flex justify-evenly">
 		<p>Stage: {data.stage}</p>
-		<Button type="submit" variant="outline" onsubmit={() => invalidateAll()}>Submit</Button>
+		<Button type="submit" variant="outline">Submit</Button>
 		<p>High Score: {data.highscore}</p>
 	</div>
 </form>
diff --git a/src/routes/api/checkSolve/+server.ts b/src/routes/api/checkSolve/+server.ts
new file mode 100644
index 0000000..a5c31e5
--- /dev/null
+++ b/src/routes/api/checkSolve/+server.ts
@@ -0,0 +1,12 @@
+import type { AlbumSolveState } from '$lib/types';
+import { albumState } from '$lib/server/AlbumState.svelte';
+import { spotifyAPI } from '$lib/server/Spotify.svelte';
+import { json } from '@sveltejs/kit';
+
+export async function POST({ request }) {
+	const { state }: { state: AlbumSolveState[] } = request.json();
+
+	const albums: SpotifyApi.AlbumObjectSimplified[] = [];
+
+	return json({ albums: albums });
+}