feat(theme-v2): add detachable header widget, fix preview thumbnail rendering, and improve wineskin launch

- Support detaching title header to canvas with full, compact, and minimal variants
- Render detached widgets and respect chrome layout configuration in theme preview generator
- Fix macOS Wineskin launching by adding Frameworks to DYLD_FALLBACK_LIBRARY_PATH
- Fix launch button interactive handling, scaling margins, and icon-only states
- Improve player widget styling and variant display
This commit is contained in:
HorizonCode committed 2026-10-31 21:00:14 +01:00
1 parent 386c829455
commit 7648d1fd4c
10 files changed
+4190 -488

No files matched your search

+682 -6
View File
@@ -2,6 +2,9 @@
import ThemeCustomLayer from '$lib/components/theme/ThemeCustomLayer.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { currentUserInfo } from '$lib/data';
import { launching as launchingStore } from '$lib/global';
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import { fontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
@@ -39,7 +42,19 @@
type ThemeV2Point,
type ThemeV2ScriptValue,
} from '$lib/themeV2';
import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte';
import { currentUser } from '$lib/userAuthentication';
import { countryCodeToEmoji, formatRelativeTime, openURL } from '$lib/utils';
import {
Boxes,
ChevronRight,
Clock,
Medal,
Move,
RotateCw,
TrendingDown,
TrendingUp,
Trophy,
} from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { TextMorph } from 'torph/svelte';
@@ -62,6 +77,10 @@
visibility = {},
groupVisibility = {},
variables = {},
launching = undefined,
launchProgress = -1,
launchText = 'Launch',
launchDisabled = false,
oninteraction,
onselect,
onselectmany,
@@ -91,6 +110,10 @@
groupVisibility?: Record<string, boolean>;
/** Rule variables available to `{name}` placeholders in text layers. */
variables?: Record<string, ThemeV2ScriptValue>;
launching?: boolean;
launchProgress?: number;
launchText?: string;
launchDisabled?: boolean;
oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
@@ -103,6 +126,8 @@
onbuttonaction?: (action: ThemeV2ButtonAction) => void;
} = $props();
const isLaunching = $derived(launching !== undefined ? launching : $launchingStore);
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
const ancestorsOf = (element: ThemeV2Element) => themeGroupAncestors(groups, element);
@@ -138,10 +163,18 @@
(element) =>
(element.type === 'data' && element.field.startsWith('clock.')) ||
element.type === 'clock' ||
element.type === 'countdown'
element.type === 'countdown' ||
(element.type === 'widget' && element.widget === 'header')
)
);
const timeGreeting = $derived.by(() => {
const hour = clockNow.getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
});
$effect(() => {
if (!usesClock) return;
const timer = window.setInterval(() => (clockNow = new Date()), 1000);
@@ -1560,9 +1593,13 @@
/>
{/key}
{:else if element.type === 'button'}
{@const isActionLaunch = element.action?.type === 'launch'}
<button
type="button"
class="theme-button flex h-full w-full items-center justify-center gap-2 px-4"
class="theme-button flex h-full w-full items-center justify-center gap-2 px-4 {isActionLaunch &&
isLaunching
? 'cursor-wait brightness-90'
: ''}"
style="--theme-button-hover:{element.hoverBackground ??
'#ff4d94'};background:{element.background ?? '#ff006e'};color:{element.color ??
'#ffffff'};border:{element.borderWidth
@@ -1570,16 +1607,31 @@
: 'none'};border-radius:{element.radius ?? 10}px;font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 16}px;font-weight:{element.fontWeight ?? 600};"
disabled={isActionLaunch && (launchDisabled || isLaunching)}
onclick={(event) => {
if (interactive) return;
if (interactive) {
if (event.shiftKey || event.ctrlKey || event.metaKey) {
event.stopPropagation();
onbuttonaction?.(element.action);
}
return;
}
if (isActionLaunch && (launchDisabled || isLaunching)) return;
event.stopPropagation();
triggerInteraction(element, 'click');
onbuttonaction?.(element.action);
}}
>
{#if element.icon}
{#if isActionLaunch && isLaunching}
<ThemeIcon name="loader" set={iconSet} size="1.1em" class="animate-spin" />
{:else if element.icon}
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
{/if}
{#if isMorphEnabled(element)}
{#if isActionLaunch && isLaunching}
<span class="truncate">
{launchProgress > 0 ? `${launchText} (${Math.round(launchProgress)}%)` : launchText}
</span>
{:else if isMorphEnabled(element)}
<TextMorph
text={String(interpolateThemeText(element.label, variables) ?? '')}
duration={morphDuration(element)}
@@ -1800,6 +1852,630 @@
</span>
{/if}
</div>
{:else if element.type === 'widget'}
<div class="pointer-events-auto h-full w-full select-none overflow-visible">
{#if element.widget === 'launch-button'}
{#if element.variant === 'icon-only' || element.variant === 'minimal'}
{@const btnRadius = Math.max(
12,
Math.round(Math.min(element.width, element.height) / 2) - 6
)}
{@const btnCircumference = 2 * Math.PI * btnRadius}
{@const strokeOffset =
launchProgress === -1
? btnCircumference * 0.7
: btnCircumference -
(Math.max(0, Math.min(100, launchProgress)) / 100) * btnCircumference}
<button
type="button"
class="relative flex h-full w-full items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 transition-all {isLaunching
? 'cursor-wait brightness-95'
: launchDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:scale-105 hover:brightness-110 active:scale-95'}"
disabled={launchDisabled || isLaunching}
onclick={(e) => {
e.stopPropagation();
if (!launchDisabled && !isLaunching) {
onbuttonaction?.({ type: 'launch' });
}
}}
title={isLaunching ? launchText : 'Launch osu!'}
>
{#if isLaunching}
<!-- Rotating progress ring SVG -->
<svg
class="absolute inset-0 h-full w-full {launchProgress === -1
? 'animate-spin'
: '-rotate-90'}"
viewBox="0 0 {element.width} {element.height}"
>
<circle
cx={element.width / 2}
cy={element.height / 2}
r={btnRadius}
fill="none"
stroke="rgba(0,0,0,0.25)"
stroke-width="3"
/>
<circle
cx={element.width / 2}
cy={element.height / 2}
r={btnRadius}
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray={btnCircumference}
stroke-dashoffset={strokeOffset}
class="transition-[stroke-dashoffset] duration-150"
/>
</svg>
{#if launchProgress > 0}
<span
class="relative z-10 text-[11px] font-black tracking-tight text-primary-foreground"
>
{Math.round(launchProgress)}%
</span>
{:else}
<ThemeIcon
name="loader"
set={iconSet}
class="relative z-10 size-6 animate-spin text-primary-foreground"
/>
{/if}
{:else}
<ThemeIcon name="launch" set={iconSet} class="size-6 fill-current ml-0.5" />
{/if}
</button>
{:else if element.variant === 'compact'}
<button
type="button"
class="relative flex h-full w-full items-center justify-center gap-2 rounded-full bg-primary px-3 text-xs font-bold text-primary-foreground shadow-md shadow-primary/25 transition-all {isLaunching
? 'cursor-wait brightness-95'
: launchDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]'}"
disabled={launchDisabled || isLaunching}
onclick={(e) => {
e.stopPropagation();
if (!launchDisabled && !isLaunching) {
onbuttonaction?.({ type: 'launch' });
}
}}
title={isLaunching ? launchText : 'Launch osu!'}
>
{#if isLaunching}
<ThemeIcon
name="loader"
set={iconSet}
class="size-3.5 animate-spin fill-current"
/>
<span>{launchProgress > 0 ? `${Math.round(launchProgress)}%` : launchText}</span
>
{:else}
<ThemeIcon name="launch" set={iconSet} class="size-3.5 fill-current" />
<span>Launch</span>
{/if}
</button>
{:else}
<button
type="button"
class="relative flex h-full w-full items-center justify-center gap-2.5 rounded-full bg-primary px-5 font-bold text-primary-foreground shadow-lg shadow-primary/30 transition-all {isLaunching
? 'cursor-wait brightness-95'
: launchDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]'}"
disabled={launchDisabled || isLaunching}
onclick={(e) => {
e.stopPropagation();
if (!launchDisabled && !isLaunching) {
onbuttonaction?.({ type: 'launch' });
}
}}
title={isLaunching ? launchText : 'Launch osu!'}
>
<div class="flex size-7 items-center justify-center rounded-full bg-black/20">
{#if isLaunching}
<ThemeIcon
name="loader"
set={iconSet}
class="size-3.5 animate-spin text-primary-foreground"
/>
{:else}
<ThemeIcon
name="launch"
set={iconSet}
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
/>
{/if}
</div>
<span class="text-sm font-bold tracking-wide">
{isLaunching
? launchProgress > 0
? `${launchText} (${Math.round(launchProgress)}%)`
: launchText
: 'Launch'}
</span>
</button>
{/if}
{:else if element.widget === 'player-card'}
{@const playerStats =
$currentUserInfo?.stats?.['0'] ?? $themeGlobalData?.user?.stats?.osu}
{@const accuracyVal =
playerStats && 'acc' in playerStats
? playerStats.acc
: playerStats && 'accuracy' in playerStats
? playerStats.accuracy
: 98.3}
{@const playerId = $currentUser?.id ?? $currentUserInfo?.info?.id ?? 0}
{@const playerName = $currentUser?.name ?? 'Player'}
{@const country = $currentUserInfo?.info?.country}
{@const levelVal = playerStats && 'level' in playerStats ? playerStats.level : 1}
{@const levelProgress =
playerStats && 'level_progress' in playerStats ? playerStats.level_progress : 75}
{@const rankVal = playerStats?.rank ? playerStats.rank : 102}
{@const countryRankVal =
playerStats && 'country_rank' in playerStats ? playerStats.country_rank : undefined}
{@const ppVal = playerStats?.pp ? playerStats.pp : 4521}
{@const playsVal = playerStats?.plays
? Number(playerStats.plays).toLocaleString()
: '11,813'}
{#if element.variant === 'minimal'}
<div
class="flex h-full w-full items-center gap-2.5 rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="relative shrink-0">
<Avatar.Root
class="size-10 rounded-xl border border-white/15 bg-theme-900 shadow"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={18} />
</Avatar.Fallback>
</Avatar.Root>
{#if country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<span class="block truncate text-xs font-bold text-white">{playerName}</span>
<span class="block text-[10px] font-medium text-muted-foreground">
#{rankVal} · {ppVal}pp
</span>
</div>
<span
class="shrink-0 rounded-md bg-primary/20 px-1.5 py-0.5 text-[9px] font-bold text-primary"
>
Lv.{levelVal}
</span>
</div>
{:else if element.variant === 'compact'}
<div
class="flex h-full w-full flex-col justify-between rounded-2xl border border-white/10 bg-black/55 p-3 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<Avatar.Root
class="size-11 rounded-xl border border-white/15 bg-theme-900 shadow"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={20} />
</Avatar.Fallback>
</Avatar.Root>
{#if country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="block truncate text-sm font-bold text-white">{playerName}</span
>
<span
class="rounded bg-primary/20 px-1.5 py-0.5 text-[9px] font-bold text-primary"
>
Lv.{levelVal}
</span>
</div>
<span class="block text-[10px] font-medium text-muted-foreground">
#{rankVal} · {ppVal}pp
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-white/5 pt-2 text-xs">
<div
class="flex items-center justify-between rounded-lg bg-white/5 px-2.5 py-1"
>
<span class="text-[9px] font-medium text-muted-foreground">Accuracy</span>
<span class="text-[11px] font-bold text-white">{accuracyVal.toFixed(1)}%</span
>
</div>
<div
class="flex items-center justify-between rounded-lg bg-white/5 px-2.5 py-1"
>
<span class="text-[9px] font-medium text-muted-foreground">Plays</span>
<span class="text-[11px] font-bold text-white">{playsVal}</span>
</div>
</div>
</div>
{:else}
<div
class="flex h-full w-full flex-col justify-between rounded-2xl border border-white/10 bg-black/55 p-3.5 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<Avatar.Root
class="size-12 rounded-xl border border-white/15 bg-theme-900 shadow-md"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={22} />
</Avatar.Fallback>
</Avatar.Root>
{#if country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[10px] leading-none shadow"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
</div>
<div class="min-w-0 flex flex-col">
<span class="block truncate text-sm font-bold text-white">{playerName}</span
>
<div class="flex items-center gap-2 mt-1">
<span class="text-[11px] font-semibold text-white/80 shrink-0">
Lv.{levelVal}
</span>
<div class="w-20 rounded-full bg-white/10 h-1.5 overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-all"
style="width: {levelProgress}%"
></div>
</div>
<span class="text-[10px] text-muted-foreground font-mono">
{levelProgress}%
</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-white/5 pt-2 mt-2">
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<div class="flex items-center justify-between">
<span class="text-[9px] font-medium text-muted-foreground">Rank</span>
{#if countryRankVal}
<span class="text-[9px] font-bold text-primary">#{countryRankVal}</span>
{/if}
</div>
<span class="text-xs font-bold text-white mt-0.5">#{rankVal}</span>
</div>
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<span class="text-[9px] font-medium text-muted-foreground">Performance</span>
<span class="text-xs font-bold text-primary mt-0.5">{ppVal}pp</span>
</div>
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<span class="text-[9px] font-medium text-muted-foreground">Accuracy</span>
<span class="text-xs font-bold text-white mt-0.5"
>{accuracyVal.toFixed(1)}%</span
>
</div>
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<span class="text-[9px] font-medium text-muted-foreground">Plays</span>
<span class="text-xs font-bold text-white mt-0.5">{playsVal}</span>
</div>
</div>
</div>
{/if}
{:else if element.widget === 'recent-activity'}
{@const event = $currentUserInfo?.events?.[0]}
{#if element.variant === 'minimal'}
<div
class="flex h-full w-full items-center gap-2.5 overflow-hidden rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-theme-50 shadow-xl backdrop-blur-md"
>
{#if event?.setId}
<img
src="https://assets.ppy.sh/beatmaps/{event.setId}/covers/cover.jpg"
alt={event.title}
class="size-10 shrink-0 rounded-lg object-cover"
/>
{:else}
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-theme-900 text-primary"
>
<Trophy class="size-4" />
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="block truncate text-xs font-bold text-white"
>{event?.title ?? 'Space Pirates'}</span
>
<span class="block truncate text-[10px] text-muted-foreground">
{event?.artist ?? 'Waterflame'} · {event
? formatRelativeTime(event.time)
: '5d ago'}
</span>
</div>
<span
class="shrink-0 rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
>
{event?.grade ?? 'S'}
</span>
</div>
{:else if element.variant === 'compact'}
<div
class="relative flex h-full w-full flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-black/55 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="relative h-14 w-full shrink-0 overflow-hidden bg-theme-900/80">
{#if event?.setId}
<img
src="https://assets.ppy.sh/beatmaps/{event.setId}/covers/cover.jpg"
alt={event.title}
class="h-full w-full object-cover"
/>
{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
></div>
<div class="absolute top-1.5 left-2 right-2 flex items-center justify-between">
<span class="text-[9px] font-bold uppercase tracking-wider text-white/90"
>Recent Activity</span
>
<span
class="rounded border border-amber-300/40 bg-amber-400/20 px-1 py-0.2 text-[9px] font-black text-amber-300"
>
{event?.grade ?? 'S'}
</span>
</div>
<div
class="absolute bottom-1 left-2 right-2 truncate text-xs font-bold text-white"
>
{event?.title ?? 'Space Pirates'}
</div>
</div>
<div class="flex items-center justify-between px-2.5 py-1.5 text-[10px]">
<span class="max-w-[180px] truncate font-semibold text-primary"
>[{event?.version ?? 'Galactic Thieves'}]</span
>
<span class="font-bold text-amber-300">Rank #{event?.rank ?? 1}</span>
</div>
</div>
{:else}
<div
class="relative flex h-full w-full flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-black/55 text-theme-50 shadow-xl backdrop-blur-md"
>
<!-- Cover Banner -->
<div class="relative h-20 w-full shrink-0 overflow-hidden bg-theme-900/80">
{#if event?.setId}
<img
src="https://assets.ppy.sh/beatmaps/{event.setId}/covers/cover.jpg"
alt={event.title}
class="h-full w-full object-cover"
/>
{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
></div>
<div
class="absolute top-2 left-2.5 right-2.5 flex items-center justify-between"
>
<span
class="rounded-full border border-white/10 bg-black/60 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-white/90"
>
Recent Activity
</span>
<span
class="rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
>
{event?.grade ?? 'S'}
</span>
</div>
<div class="absolute bottom-1.5 left-2.5 right-2.5 flex min-w-0 flex-col">
<span class="truncate text-xs font-bold text-white"
>{event?.title ?? 'Space Pirates'}</span
>
<span class="truncate text-[10px] text-white/70"
>{event?.artist ?? 'Waterflame'}</span
>
</div>
</div>
<!-- Metrics Body -->
<div class="flex flex-1 flex-col justify-center gap-2 p-2.5">
<div class="flex items-center justify-between gap-1 text-[11px]">
<span class="max-w-[180px] truncate font-semibold text-primary">
[{event?.version ?? 'Galactic Thieves'}]
</span>
<div
class="flex items-center gap-1 rounded-md border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
>
<Trophy class="size-3" />
<span>Rank #{event?.rank ?? 1}</span>
</div>
</div>
<div
class="flex items-center justify-between border-t border-white/5 pt-1.5 text-[10px] text-muted-foreground"
>
<span class="flex items-center gap-1 font-mono">
<Clock class="size-2.5" />
{event ? formatRelativeTime(event.time) : '5d ago'}
</span>
<span class="text-white/60">ez-pp.farm</span>
</div>
</div>
</div>
{/if}
{:else if element.widget === 'telemetry'}
<div
class="flex h-full w-full items-center gap-2 rounded-full border border-white/10 bg-black/40 px-3 text-xs text-white/80 backdrop-blur-md"
>
<div class="flex items-center gap-1">
<ThemeIcon name="wifi" set={iconSet} class="size-3 text-emerald-400" />
<span class="text-[10px]">{$themeGlobalData?.server?.ping ?? 19}ms</span>
</div>
<span
class="rounded-full border border-white/5 bg-white/10 px-2 py-0.5 text-[9px] text-white/50"
>
{$themeGlobalData?.osu?.stream ?? 'stable'}
</span>
</div>
{:else if element.widget === 'header'}
{#if element.variant === 'minimal'}
<div class="flex h-full items-center">
<span
class="theme-heading text-2xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
</div>
{:else if element.variant === 'compact'}
<div class="flex h-full items-center gap-3">
<span
class="theme-heading text-2xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
<span
class="rounded-full border border-white/10 bg-black/40 px-2.5 py-0.5 text-xs font-medium text-white/80 shadow-sm backdrop-blur-md"
>
{timeGreeting},
<span class="font-semibold text-white">{$currentUser?.name ?? 'Guest'}</span>!
</span>
</div>
{:else}
<div class="flex h-full flex-col justify-center">
<span
class="theme-heading text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
{timeGreeting},
<span class="font-semibold text-white">{$currentUser?.name ?? 'Guest'}</span>!
</span>
</div>
{/if}
{:else if element.widget === 'avatar'}
{@const shapeClass =
element.variant === 'rounded'
? 'rounded-2xl'
: element.variant === 'square'
? 'rounded-md'
: 'rounded-full'}
{@const playerId = $currentUser?.id ?? $currentUserInfo?.info?.id ?? 0}
{@const playerName = $currentUser?.name ?? 'Player'}
{@const country = $currentUserInfo?.info?.country}
{@const level = $currentUserInfo?.stats?.['0']?.level}
<div
class="group/avatar relative flex h-full w-full items-center justify-center {interactive
? ''
: 'cursor-pointer'}"
role="button"
tabindex="0"
onclick={(e) => {
if (!interactive && playerId) {
e.stopPropagation();
void openURL(`https://ez-pp.farm/u/${playerId}`);
}
}}
onkeydown={(e) => {
if (!interactive && playerId && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
e.stopPropagation();
void openURL(`https://ez-pp.farm/u/${playerId}`);
}
}}
title={playerName}
>
<!-- Online Glow Ring -->
{#if element.showOnlineRing !== false}
<div
class="absolute inset-0 {shapeClass} ring-2 ring-primary/40 ring-offset-2 ring-offset-black/50 transition-all duration-300 group-hover/avatar:ring-primary/80"
></div>
{/if}
<!-- Avatar Image / Fallback -->
<Avatar.Root
class="h-full w-full {shapeClass} overflow-hidden border border-white/15 bg-theme-900 shadow-lg transition-transform duration-300 group-hover/avatar:scale-105"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon
name="user"
set={iconSet}
size={Math.max(
16,
Math.round(Math.min(element.width, element.height) * 0.45)
)}
/>
</Avatar.Fallback>
</Avatar.Root>
<!-- Country Flag Emoji Badge -->
{#if element.showCountry !== false && country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[10px] leading-none shadow-md backdrop-blur-sm"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
<!-- Level Badge -->
{#if element.showLevel && level !== undefined}
<span
class="absolute -top-1 -left-1 flex items-center justify-center rounded-md border border-primary/40 bg-black/90 px-1 py-0.5 text-[9px] font-bold leading-none text-primary shadow-md"
>
Lv.{level}
</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>