mirror of
https://github.com/SrIzan10/lofi.git
synced 2026-06-06 00:56:53 +00:00
feat: stats page redesign
This commit is contained in:
@@ -241,7 +241,10 @@
|
||||
</span>
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="!p-0 !gap-0 overflow-hidden max-w-md w-[95vw]">
|
||||
<Dialog.Content
|
||||
class="!p-0 !gap-0 overflow-hidden max-w-md w-[95vw]"
|
||||
style="--text-color: rgba(255, 255, 255, 0.92); --text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); --foreground: 0 0% 98%; --muted-foreground: 0 0% 72%;"
|
||||
>
|
||||
<Dialog.Header class="p-5 border-b border-white/[0.06]">
|
||||
<Dialog.Title class="!text-lg !font-semibold tracking-tight">
|
||||
{user ? 'Account Settings' : 'Welcome Back'}
|
||||
@@ -332,8 +335,8 @@
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-lg bg-white/[0.02] border border-dashed border-white/[0.1] text-white/40"
|
||||
>
|
||||
<Fingerprint class="size-5 opacity-50" />
|
||||
<p class="text-sm">No passkeys added yet</p>
|
||||
<Fingerprint class="size-5 !text-white/40" />
|
||||
<p class="text-sm !text-white/40">No passkeys added yet</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -350,7 +353,11 @@
|
||||
class="!h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onclick={addPasskey} disabled={busyAction === 'add-passkey'} class="!h-10 px-4">
|
||||
<Button
|
||||
onclick={addPasskey}
|
||||
disabled={busyAction === 'add-passkey'}
|
||||
class="!h-10 px-4 !text-white/80 hover:!text-white"
|
||||
>
|
||||
{#if busyAction === 'add-passkey'}
|
||||
<Loader2 class="size-4 animate-spin" />
|
||||
{:else}
|
||||
@@ -376,7 +383,7 @@
|
||||
variant="ghost"
|
||||
onclick={signOut}
|
||||
disabled={busyAction === 'sign-out'}
|
||||
class="w-full justify-center gap-2 text-white/60 hover:text-white hover:bg-white/[0.06]"
|
||||
class="w-full justify-center gap-2 !text-white/60 hover:!text-white hover:bg-white/[0.06]"
|
||||
>
|
||||
{#if busyAction === 'sign-out'}
|
||||
<Loader2 class="size-4 animate-spin" />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { state as appState } from '@/state.svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
|
||||
type TopSong = {
|
||||
@@ -28,6 +27,7 @@
|
||||
let stats = $state<StatsResponse | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let activeTab = $state<'stations' | 'songs'>('stations');
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const totalMinutes = Math.floor(seconds / 60);
|
||||
@@ -42,10 +42,19 @@
|
||||
|
||||
const stationName = (stationId: number | null) => {
|
||||
const station = appState.stations.find((item) => item.id === stationId);
|
||||
|
||||
return station?.name ?? 'Unknown station';
|
||||
};
|
||||
|
||||
const maxStationSeconds = $derived.by(() => {
|
||||
if (!stats) return 1;
|
||||
return Math.max(...stats.topStations.map((s) => s.seconds), 1);
|
||||
});
|
||||
|
||||
const maxSongSeconds = $derived.by(() => {
|
||||
if (!stats) return 1;
|
||||
return Math.max(...stats.topSongs.map((s) => s.seconds), 1);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
@@ -66,92 +75,112 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-4 text-foreground">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-foreground/50">Statistics</p>
|
||||
<h2 class="text-2xl font-semibold leading-tight">Listening overview</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col p-5 text-foreground">
|
||||
{#if isLoading}
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-4 text-sm text-foreground/70">
|
||||
Loading stats...
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-3 text-foreground/40">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-foreground/15 border-t-foreground/60"></div>
|
||||
<span class="text-xs tracking-wide">Loading stats</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-4 text-sm text-foreground/70">
|
||||
{error}
|
||||
<div class="flex flex-1 flex-col items-center justify-center text-center">
|
||||
<p class="text-sm text-foreground/50">{error}</p>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.16em] text-foreground/50">Today</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{formatDuration(stats.todaySeconds)}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.16em] text-foreground/50">All time</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{formatDuration(stats.totalSeconds)}</p>
|
||||
<div class="mb-5 shrink-0">
|
||||
<p class="mb-2 text-[10px] font-medium uppercase tracking-[0.2em] text-foreground/40">
|
||||
Listening Stats
|
||||
</p>
|
||||
<div class="flex items-baseline gap-5">
|
||||
<div>
|
||||
<span class="text-3xl">{formatDuration(stats.todaySeconds)}</span>
|
||||
<span class="ml-1.5 text-[10px] font-medium uppercase tracking-wider text-foreground/40">Today</span>
|
||||
</div>
|
||||
<div class="h-3 w-px bg-white/10"></div>
|
||||
<div>
|
||||
<span class="text-3xl">{formatDuration(stats.totalSeconds)}</span>
|
||||
<span class="ml-1.5 text-[10px] font-medium uppercase tracking-wider text-foreground/40">All time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium text-foreground/80">Top stations</h3>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({
|
||||
variant: 'default',
|
||||
size: 'sm',
|
||||
class: 'h-7 px-2 text-xs'
|
||||
})}
|
||||
aria-label="Show top songs"
|
||||
>
|
||||
<ListMusic class="size-3.5" />
|
||||
Songs
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="w-72 max-h-72 overflow-y-auto" align="end">
|
||||
<DropdownMenu.Label>Top songs</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
{#each stats.topSongs as song}
|
||||
<DropdownMenu.Item class="gap-3 p-2">
|
||||
{#if song.image}
|
||||
<img src={song.image} alt="" class="size-9 shrink-0 rounded-md object-cover" />
|
||||
{:else}
|
||||
<div class="size-9 shrink-0 rounded-md bg-white/10"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{song.title ?? song.fileId}</p>
|
||||
<p class="truncate text-xs text-foreground/55">
|
||||
{song.artists ?? 'Unknown artist'}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-foreground/60">
|
||||
{formatDuration(song.seconds)}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item disabled class="text-foreground/60">
|
||||
No top songs yet
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<div class="mb-3 flex shrink-0 items-center justify-between">
|
||||
<div class="flex rounded-lg border border-white/[0.06] bg-white/[0.03] p-0.5">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1 text-[11px] font-medium transition-all duration-200 {activeTab === 'stations' ? 'bg-white/10 text-foreground shadow-sm' : 'text-foreground/40 hover:text-foreground/70'}"
|
||||
onclick={() => (activeTab = 'stations')}
|
||||
>
|
||||
<Radio class="size-3" />
|
||||
Stations
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1 text-[11px] font-medium transition-all duration-200 {activeTab === 'songs' ? 'bg-white/10 text-foreground shadow-sm' : 'text-foreground/40 hover:text-foreground/70'}"
|
||||
onclick={() => (activeTab = 'songs')}
|
||||
>
|
||||
<ListMusic class="size-3" />
|
||||
Songs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 space-y-2 overflow-y-auto pr-1">
|
||||
{#each stats.topStations as station}
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<span class="min-w-0 truncate">{stationName(station.stationId)}</span>
|
||||
<span class="shrink-0 text-foreground/60">{formatDuration(station.seconds)}</span>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar">
|
||||
{#if activeTab === 'stations'}
|
||||
{#each stats.topStations as station, i}
|
||||
<div class="flex items-center gap-3 border-b border-white/[0.04] py-2.5 last:border-0">
|
||||
<span class="w-4 text-right text-[10px] font-mono text-foreground/25">{i + 1}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<span class="truncate text-sm text-foreground/90">
|
||||
{stationName(station.stationId)}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs font-mono text-foreground/40">
|
||||
{formatDuration(station.seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-[2px] overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
class="h-full rounded-full bg-white/20 transition-all duration-700 ease-out"
|
||||
style="width: {(station.seconds / maxStationSeconds) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="rounded-lg border border-white/10 bg-white/10 p-3 text-sm text-foreground/60">
|
||||
Station stats will appear after you listen for a bit.
|
||||
</p>
|
||||
<div class="flex h-32 items-center justify-center">
|
||||
<p class="text-xs text-foreground/30">Station stats will appear after you listen for a bit.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
{#each stats.topSongs as song, i}
|
||||
<div class="flex items-center gap-3 border-b border-white/[0.04] py-2.5 last:border-0">
|
||||
<span class="w-4 text-right text-[10px] font-mono text-foreground/25">{i + 1}</span>
|
||||
{#if song.image}
|
||||
<img src={song.image} alt="" class="size-7 shrink-0 rounded-sm object-cover opacity-80" />
|
||||
{:else}
|
||||
<div class="size-7 shrink-0 rounded-sm bg-white/[0.06]"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<span class="truncate text-sm text-foreground/90">
|
||||
{song.title ?? song.fileId}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs font-mono text-foreground/40">
|
||||
{formatDuration(song.seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-[2px] overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
class="h-full rounded-full bg-white/20 transition-all duration-700 ease-out"
|
||||
style="width: {(song.seconds / maxSongSeconds) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-32 items-center justify-center">
|
||||
<p class="text-xs text-foreground/30">No top songs yet.</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
{#if show}
|
||||
<div
|
||||
bind:this={windowRef}
|
||||
class="fixed flex flex-col bg-foreground/20 backdrop-blur-md border border-foreground/20 shadow-lg rounded-lg overflow-hidden"
|
||||
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex};"
|
||||
class="fixed flex flex-col overflow-hidden rounded-2xl border border-white/[0.1] bg-black/[0.48] text-white shadow-[0_8px_32px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.1)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-0 before:rounded-2xl before:bg-gradient-to-br before:from-white/[0.08] before:to-transparent"
|
||||
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex}; --foreground: 0 0% 98%; --muted-foreground: 0 0% 72%; --text-color: rgba(255, 255, 255, 0.92); --text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);"
|
||||
onmousedown={handleMouseDown}
|
||||
role="dialog"
|
||||
tabindex="0"
|
||||
@@ -121,14 +121,14 @@
|
||||
{#if showTitleBar}
|
||||
<div
|
||||
bind:this={headerRef}
|
||||
class="h-8 px-3 flex items-center justify-between bg-black/10 border-b border-white/10 select-none"
|
||||
class="relative z-10 flex h-8 items-center justify-between border-b border-white/[0.08] bg-black/10 px-3 select-none"
|
||||
style="cursor: {isDragging ? 'grabbing' : 'grab'};"
|
||||
>
|
||||
<span class="text-sm font-medium text-foreground/90">{title}</span>
|
||||
<span class="text-sm font-medium text-white/90">{title}</span>
|
||||
{#if showCloseButton}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="w-5 h-5 flex items-center justify-center text-foreground/70 hover:text-foreground hover:bg-red-500/50 rounded-sm transition-colors"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-sm text-white/70 transition-colors hover:bg-red-500/45 hover:text-white"
|
||||
aria-label="Close window"
|
||||
>
|
||||
<X class="size-4" />
|
||||
@@ -137,8 +137,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 p-1 overflow-auto bg-transparent" role="dialog" tabindex="0">
|
||||
<div class="relative z-10 flex-1 overflow-auto bg-transparent p-1" role="dialog" tabindex="0">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user