+ import { Tooltip } from 'bits-ui';
+
+ export type ImageGridItem = {
+ imageUrl: string | null;
+ link: string;
+ label: string;
+ };
+
+ let {
+ items,
+ layout = 'grid',
+ shape = 'square',
+ tooltip = false
+ }: {
+ items: ImageGridItem[];
+ layout?: 'grid' | 'cinema';
+ shape?: 'square' | 'circle';
+ tooltip?: boolean;
+ } = $props();
+
+ let containerWidth = $state(0);
+ let containerHeight = $state(0);
+
+ let totalItems = $derived(items.length);
+
+ const GAP = 6;
+ const MIN_SIZE = 16;
+ const MAX_SIZE = 120;
+
+ function cinemaCapacity(size: number, availW: number, availH: number): number {
+ const colsWide = Math.floor((availW + GAP) / (size + GAP));
+ if (colsWide < 1) return 0;
+ const colsNarrow = Math.max(1, colsWide - 1);
+ const maxRows = Math.floor((availH + GAP) / (size + GAP));
+ let capacity = 0;
+ for (let r = 0; r < maxRows; r++) {
+ capacity += r % 2 === 0 ? colsNarrow : colsWide;
+ }
+ return capacity;
+ }
+
+ function gridCapacity(size: number, availW: number, availH: number): number {
+ const cols = Math.floor((availW + GAP) / (size + GAP));
+ const rows = Math.floor((availH + GAP) / (size + GAP));
+ return cols * rows;
+ }
+
+ let computedSize = $derived.by(() => {
+ if (!containerWidth || !containerHeight || totalItems === 0) return 40;
+
+ let lo = MIN_SIZE;
+ let hi = MAX_SIZE;
+ const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity;
+
+ while (lo <= hi) {
+ const mid = Math.floor((lo + hi) / 2);
+ const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0);
+ const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0);
+ if (availW <= 0 || availH <= 0) {
+ hi = mid - 1;
+ continue;
+ }
+ if (capacityFn(mid, availW, availH) >= totalItems) {
+ lo = mid + 1;
+ } else {
+ hi = mid - 1;
+ }
+ }
+
+ return Math.max(MIN_SIZE, hi);
+ });
+
+ let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0);
+
+ let rows = $derived.by(() => {
+ const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0);
+ if (availW <= 0) return [] as ImageGridItem[][];
+
+ const colsWide = Math.floor((availW + GAP) / (computedSize + GAP));
+ const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide;
+
+ const rowSizes: number[] = [];
+ let remaining = items.length;
+ let rowNum = 0;
+ while (remaining > 0) {
+ const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide;
+ rowSizes.push(Math.min(cols, remaining));
+ remaining -= cols;
+ rowNum++;
+ }
+ rowSizes.reverse();
+
+ const result: ImageGridItem[][] = [];
+ let idx = 0;
+ for (const size of rowSizes) {
+ result.push(items.slice(idx, idx + size));
+ idx += size;
+ }
+ return result;
+ });
+
+ let textSize = $derived(
+ computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm'
+ );
+
+ let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg');
+
+
+{#snippet gridItem(item: ImageGridItem)}
+ {#if item.imageUrl}
+

+ {:else}
+
+
+ {item.label.charAt(0).toUpperCase()}
+
+
+ {/if}
+{/snippet}
+
+
+ {#if totalItems > 0}
+
+
+ {#each rows as row, rowIdx (rowIdx)}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/website/ThemeScript.svelte b/src/lib/website/ThemeScript.svelte
index 0c1c9ab..04922f5 100644
--- a/src/lib/website/ThemeScript.svelte
+++ b/src/lib/website/ThemeScript.svelte
@@ -10,8 +10,23 @@
} = $props();
const allAccentColors = [
- 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal',
- 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose'
+ 'red',
+ 'orange',
+ 'amber',
+ 'yellow',
+ 'lime',
+ 'green',
+ 'emerald',
+ 'teal',
+ 'cyan',
+ 'sky',
+ 'blue',
+ 'indigo',
+ 'violet',
+ 'purple',
+ 'fuchsia',
+ 'pink',
+ 'rose'
];
const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate'];
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index f76916d..4153bfe 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,6 +1,7 @@
-{@render children()}
+
+ {@render children()}
+
diff --git a/src/routes/api/lastfm/+server.ts b/src/routes/api/lastfm/+server.ts
new file mode 100644
index 0000000..522330f
--- /dev/null
+++ b/src/routes/api/lastfm/+server.ts
@@ -0,0 +1,89 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/private';
+
+const LASTFM_API_URL = 'https://ws.audioscrobbler.com/2.0/';
+
+const ALLOWED_METHODS = [
+ 'user.getRecentTracks',
+ 'user.getTopTracks',
+ 'user.getTopAlbums',
+ 'user.getInfo'
+];
+
+const CACHE_TTL: Record
= {
+ 'user.getRecentTracks': 15 * 60 * 1000,
+ 'user.getTopTracks': 60 * 60 * 1000,
+ 'user.getTopAlbums': 60 * 60 * 1000,
+ 'user.getInfo': 12 * 60 * 60 * 1000
+};
+
+export const GET: RequestHandler = async ({ url, platform }) => {
+ const method = url.searchParams.get('method');
+ const user = url.searchParams.get('user');
+ const period = url.searchParams.get('period') || '7day';
+ const limit = url.searchParams.get('limit') || '50';
+
+ if (!method || !user) {
+ return json({ error: 'Missing method or user parameter' }, { status: 400 });
+ }
+
+ if (!ALLOWED_METHODS.includes(method)) {
+ return json({ error: 'Method not allowed' }, { status: 400 });
+ }
+
+ const cacheKey = `#lastfm:${method}:${user}:${period}:${limit}`;
+ const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey);
+
+ if (cachedData) {
+ const parsed = JSON.parse(cachedData);
+ const ttl = CACHE_TTL[method] || 60 * 60 * 1000;
+
+ if (Date.now() - (parsed._cachedAt || 0) < ttl) {
+ return json(parsed);
+ }
+ }
+
+ const apiKey = env?.LASTFM_API_KEY;
+ if (!apiKey) {
+ return json({ error: 'Last.fm API key not configured' }, { status: 500 });
+ }
+
+ try {
+ const params = new URLSearchParams({
+ method,
+ user,
+ api_key: apiKey,
+ format: 'json',
+ limit
+ });
+
+ if (method === 'user.getTopTracks' || method === 'user.getTopAlbums') {
+ params.set('period', period);
+ }
+
+ const response = await fetch(`${LASTFM_API_URL}?${params}`);
+
+ if (!response.ok) {
+ return json(
+ { error: 'Failed to fetch Last.fm data: ' + response.statusText },
+ { status: response.status }
+ );
+ }
+
+ const data = await response.json();
+
+ if (data.error) {
+ return json({ error: data.message || 'Last.fm API error' }, { status: 400 });
+ }
+
+ data._cachedAt = Date.now();
+
+ await platform?.env?.USER_DATA_CACHE?.put(cacheKey, JSON.stringify(data));
+
+ return json(data);
+ } catch (error) {
+ console.error('Error fetching Last.fm data:', error);
+ return json({ error: 'Failed to fetch Last.fm data' }, { status: 500 });
+ }
+};