Better styling and loading logic, save state for user session

This commit is contained in:
Mihkel Martin Kasterpalu 2025-01-21 11:42:52 +02:00
parent 19127be9a2
commit 26b6c63caf
26 changed files with 674 additions and 120 deletions

View file

@ -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>

View file

@ -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} />

View file

@ -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}
/>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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,
};

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
};

View file

@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};

View file

@ -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>

View file

@ -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();

View file

@ -8,4 +8,5 @@ export type Player = {
id: string;
stage: number;
highscore: number;
playing: boolean;
};

View file

@ -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;
}

View file

@ -3,4 +3,6 @@
let { children } = $props();
</script>
{@render children()}
<div class="flex min-h-screen items-center">
{@render children()}
</div>

View file

@ -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 };
}
};

View file

@ -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>

View file

@ -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 });
}