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:
1 parent
386c829455
commit
7648d1fd4c
10 files changed
+4190
-488
No files matched your search
@@ -71,18 +71,26 @@
|
||||
createDefaultThemeV2,
|
||||
createThemeV2Behavior,
|
||||
createThemeV2Element,
|
||||
normalizeChrome,
|
||||
normalizeThemeV2,
|
||||
themeAssetName,
|
||||
themeHasLaunchButton,
|
||||
type ThemeV2Asset,
|
||||
type ThemeV2Background,
|
||||
type ThemeV2BehaviorTarget,
|
||||
type ThemeV2ButtonAction,
|
||||
type ThemeV2Chrome,
|
||||
type ThemeV2Definition,
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Font,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2ImageElement,
|
||||
type ThemeV2LayoutMode,
|
||||
type ThemeV2LottieElement,
|
||||
type ThemeV2ScriptValue,
|
||||
type ThemeV2VideoElement,
|
||||
type ThemeV2WidgetElement,
|
||||
type ThemeV2WidgetKind,
|
||||
} from '$lib/themeV2';
|
||||
import {
|
||||
exportThemeFolder,
|
||||
@@ -94,7 +102,7 @@
|
||||
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
|
||||
import { currentUser } from '$lib/userAuthentication';
|
||||
import { osuInstallationPath } from '$lib/userSettings';
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import { formatBytes, openURL } from '$lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
@@ -105,6 +113,7 @@
|
||||
ChevronUp,
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
CircleUser,
|
||||
ClipboardPaste,
|
||||
Clock,
|
||||
Code2,
|
||||
@@ -121,6 +130,7 @@
|
||||
Image as ImageIcon,
|
||||
Keyboard,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
LoaderCircle,
|
||||
Lock,
|
||||
Maximize2,
|
||||
@@ -230,6 +240,18 @@
|
||||
|
||||
let definition = $state<ThemeV2Definition>(createDefaultThemeV2());
|
||||
let pickedAssets = $state<ThemeV2Asset[]>([]);
|
||||
let lastImageBackground = $state<Extract<ThemeV2Background, { type: 'image' }> | undefined>(
|
||||
undefined
|
||||
);
|
||||
let lastVideoBackground = $state<Extract<ThemeV2Background, { type: 'video' }> | undefined>(
|
||||
undefined
|
||||
);
|
||||
let lastColorBackground = $state<Extract<ThemeV2Background, { type: 'color' }> | undefined>(
|
||||
undefined
|
||||
);
|
||||
let lastGradientBackground = $state<Extract<ThemeV2Background, { type: 'gradient' }> | undefined>(
|
||||
undefined
|
||||
);
|
||||
let assetBase = $state('');
|
||||
let savedFolder = $state<string | undefined>(undefined);
|
||||
let editorFolderPath = $state<string | undefined>(undefined);
|
||||
@@ -292,6 +314,46 @@
|
||||
const elements = $derived(definition.elements ?? []);
|
||||
const groups = $derived(definition.groups ?? []);
|
||||
const behaviors = $derived(definition.behaviors ?? []);
|
||||
const themeChrome = $derived(
|
||||
definition.chrome ?? normalizeChrome(undefined, definition.elements)
|
||||
);
|
||||
const hasValidLaunchButton = $derived(themeHasLaunchButton(definition));
|
||||
|
||||
function updateChrome<K extends keyof ThemeV2Chrome>(key: K, value: ThemeV2Chrome[K]) {
|
||||
const current = definition.chrome ?? normalizeChrome(undefined, definition.elements);
|
||||
definition.chrome = {
|
||||
...current,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
function setLayoutMode(mode: ThemeV2LayoutMode) {
|
||||
const current = definition.chrome ?? normalizeChrome(undefined, definition.elements);
|
||||
if (mode === 'dashboard') {
|
||||
definition.chrome = {
|
||||
...current,
|
||||
layoutMode: 'dashboard',
|
||||
playerCard: true,
|
||||
recentActivity: true,
|
||||
pills: true,
|
||||
dock: true,
|
||||
};
|
||||
} else if (mode === 'classic') {
|
||||
definition.chrome = {
|
||||
...current,
|
||||
layoutMode: 'classic',
|
||||
playerCard: true,
|
||||
recentActivity: false,
|
||||
pills: false,
|
||||
dock: true,
|
||||
};
|
||||
} else {
|
||||
definition.chrome = {
|
||||
...current,
|
||||
layoutMode: 'custom',
|
||||
};
|
||||
}
|
||||
}
|
||||
const logicBadges = $derived.by(() => {
|
||||
const badges = new SvelteMap<string, { count: number; warning: boolean }>();
|
||||
|
||||
@@ -814,17 +876,24 @@
|
||||
if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => ({
|
||||
...theme,
|
||||
typography: {
|
||||
body: theme.typography?.body ?? 'Sora Variable',
|
||||
heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable',
|
||||
...(theme.typography?.mono ? { mono: theme.typography.mono } : {}),
|
||||
},
|
||||
fonts: theme.fonts ?? [],
|
||||
elements: theme.elements ?? [],
|
||||
groups: theme.groups ?? [],
|
||||
});
|
||||
const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => {
|
||||
if (theme.background.type === 'image' && theme.background.src && !lastImageBackground) {
|
||||
lastImageBackground = { ...theme.background };
|
||||
} else if (theme.background.type === 'video' && theme.background.src && !lastVideoBackground) {
|
||||
lastVideoBackground = { ...theme.background };
|
||||
}
|
||||
return {
|
||||
...theme,
|
||||
typography: {
|
||||
body: theme.typography?.body ?? 'Sora Variable',
|
||||
heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable',
|
||||
...(theme.typography?.mono ? { mono: theme.typography.mono } : {}),
|
||||
},
|
||||
fonts: theme.fonts ?? [],
|
||||
elements: theme.elements ?? [],
|
||||
groups: theme.groups ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
type EditorSnapshot = {
|
||||
definition: ThemeV2Definition;
|
||||
@@ -1899,43 +1968,81 @@
|
||||
|
||||
function setBackgroundType(type: ThemeV2Definition['background']['type']) {
|
||||
const current = definition.background;
|
||||
|
||||
// Cache the active background before switching
|
||||
if (current.type === 'image' && current.src) {
|
||||
lastImageBackground = { ...current };
|
||||
} else if (current.type === 'video' && current.src) {
|
||||
lastVideoBackground = { ...current };
|
||||
} else if (current.type === 'color') {
|
||||
lastColorBackground = { ...current };
|
||||
} else if (current.type === 'gradient') {
|
||||
lastGradientBackground = { ...current };
|
||||
}
|
||||
|
||||
const opacity = current.opacity ?? 1;
|
||||
const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0;
|
||||
const fit =
|
||||
current.type === 'image' || current.type === 'video' ? (current.fit ?? 'cover') : 'cover';
|
||||
|
||||
if (type === 'color') {
|
||||
definition.background = {
|
||||
type,
|
||||
color: current.type === 'color' ? current.color : '#181825',
|
||||
opacity,
|
||||
type: 'color',
|
||||
color: current.type === 'color' ? current.color : (lastColorBackground?.color ?? '#181825'),
|
||||
opacity: lastColorBackground?.opacity ?? opacity,
|
||||
};
|
||||
} else if (type === 'gradient') {
|
||||
definition.background = {
|
||||
type,
|
||||
from: current.type === 'gradient' ? current.from : '#181825',
|
||||
to: current.type === 'gradient' ? current.to : '#3b1d4f',
|
||||
angle: current.type === 'gradient' ? (current.angle ?? 135) : 135,
|
||||
opacity,
|
||||
type: 'gradient',
|
||||
from:
|
||||
current.type === 'gradient' ? current.from : (lastGradientBackground?.from ?? '#181825'),
|
||||
to: current.type === 'gradient' ? current.to : (lastGradientBackground?.to ?? '#3b1d4f'),
|
||||
angle:
|
||||
current.type === 'gradient'
|
||||
? (current.angle ?? 135)
|
||||
: (lastGradientBackground?.angle ?? 135),
|
||||
opacity: lastGradientBackground?.opacity ?? opacity,
|
||||
};
|
||||
} else if (type === 'video') {
|
||||
const candidateVideo =
|
||||
current.type === 'video' && current.src
|
||||
? current.src
|
||||
: lastVideoBackground?.src ||
|
||||
pickedAssets.find((a) => /\.(mp4|webm|ogv|mov)$/i.test(a.name))?.name ||
|
||||
'';
|
||||
|
||||
definition.background = {
|
||||
type,
|
||||
src: current.type === 'video' ? current.src : '',
|
||||
fit,
|
||||
opacity,
|
||||
blur,
|
||||
muted: current.type === 'video' ? current.muted !== false : true,
|
||||
type: 'video',
|
||||
src: candidateVideo,
|
||||
fit: current.type === 'video' ? fit : (lastVideoBackground?.fit ?? fit),
|
||||
opacity: current.type === 'video' ? opacity : (lastVideoBackground?.opacity ?? opacity),
|
||||
blur: current.type === 'video' ? blur : (lastVideoBackground?.blur ?? blur),
|
||||
muted:
|
||||
current.type === 'video' ? current.muted !== false : lastVideoBackground?.muted !== false,
|
||||
};
|
||||
if (candidateVideo) {
|
||||
lastVideoBackground = { ...definition.background };
|
||||
}
|
||||
definition.audio = undefined;
|
||||
} else {
|
||||
const candidateImage =
|
||||
current.type === 'image' && current.src
|
||||
? current.src
|
||||
: lastImageBackground?.src ||
|
||||
pickedAssets.find((a) => /\.(png|jpg|jpeg|webp|gif|avif)$/i.test(a.name))?.name ||
|
||||
'';
|
||||
|
||||
definition.background = {
|
||||
type,
|
||||
src: current.type === 'image' ? current.src : '',
|
||||
fit,
|
||||
opacity,
|
||||
blur,
|
||||
type: 'image',
|
||||
src: candidateImage,
|
||||
fit: current.type === 'image' ? fit : (lastImageBackground?.fit ?? fit),
|
||||
opacity: current.type === 'image' ? opacity : (lastImageBackground?.opacity ?? opacity),
|
||||
blur: current.type === 'image' ? blur : (lastImageBackground?.blur ?? blur),
|
||||
};
|
||||
if (candidateImage) {
|
||||
lastImageBackground = { ...definition.background };
|
||||
}
|
||||
}
|
||||
if (type === 'video') definition.audio = undefined;
|
||||
}
|
||||
|
||||
async function chooseBackground(type: 'image' | 'video') {
|
||||
@@ -1947,7 +2054,7 @@
|
||||
const opacity = current.opacity ?? 1;
|
||||
const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0;
|
||||
if (type === 'video') {
|
||||
definition.background = {
|
||||
const bg: Extract<ThemeV2Background, { type: 'video' }> = {
|
||||
type,
|
||||
src: asset.name,
|
||||
fit,
|
||||
@@ -1955,14 +2062,18 @@
|
||||
blur,
|
||||
muted: current.type === 'video' ? current.muted !== false : true,
|
||||
};
|
||||
definition.background = bg;
|
||||
lastVideoBackground = { ...bg };
|
||||
} else {
|
||||
definition.background = {
|
||||
const bg: Extract<ThemeV2Background, { type: 'image' }> = {
|
||||
type,
|
||||
src: asset.name,
|
||||
fit,
|
||||
opacity,
|
||||
blur,
|
||||
};
|
||||
definition.background = bg;
|
||||
lastImageBackground = { ...bg };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2101,8 +2212,36 @@
|
||||
| 'particles'
|
||||
| 'clock'
|
||||
| 'countdown'
|
||||
| 'widget',
|
||||
widgetKind: ThemeV2WidgetKind = 'launch-button'
|
||||
) {
|
||||
const element = createThemeV2Element(type, elements.length);
|
||||
if (element.type === 'widget') {
|
||||
element.widget = widgetKind;
|
||||
if (widgetKind === 'player-card') {
|
||||
element.width = 380;
|
||||
element.height = 230;
|
||||
} else if (widgetKind === 'avatar') {
|
||||
element.width = 72;
|
||||
element.height = 72;
|
||||
element.variant = 'circle';
|
||||
element.showCountry = true;
|
||||
element.showLevel = true;
|
||||
element.showOnlineRing = true;
|
||||
} else if (widgetKind === 'recent-activity') {
|
||||
element.width = 340;
|
||||
element.height = 230;
|
||||
} else if (widgetKind === 'telemetry') {
|
||||
element.width = 320;
|
||||
element.height = 42;
|
||||
} else if (widgetKind === 'header') {
|
||||
element.width = 340;
|
||||
element.height = 64;
|
||||
} else if (widgetKind === 'launch-button') {
|
||||
element.width = 210;
|
||||
element.height = 48;
|
||||
}
|
||||
}
|
||||
if (selectedGroupId) element.groupId = selectedGroupId;
|
||||
definition.elements = [...elements, element];
|
||||
selectedGroupId = undefined;
|
||||
@@ -2110,6 +2249,188 @@
|
||||
leftTab = 'layers';
|
||||
}
|
||||
|
||||
function handleDetachWidget(widgetKind: ThemeV2WidgetKind) {
|
||||
const newElements = [...(definition.elements ?? [])];
|
||||
const currentChrome = definition.chrome ?? normalizeChrome(undefined, newElements);
|
||||
|
||||
if (widgetKind === 'header') {
|
||||
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'header');
|
||||
if (!existing) {
|
||||
const headerWidget = createThemeV2Element(
|
||||
'widget',
|
||||
newElements.length
|
||||
) as ThemeV2WidgetElement;
|
||||
headerWidget.widget = 'header';
|
||||
headerWidget.name = 'Header Title';
|
||||
headerWidget.x = 88;
|
||||
headerWidget.y = 28;
|
||||
headerWidget.width = 320;
|
||||
headerWidget.height = 64;
|
||||
newElements.push(headerWidget);
|
||||
existing = headerWidget;
|
||||
}
|
||||
definition.chrome = {
|
||||
...currentChrome,
|
||||
layoutMode: 'custom',
|
||||
header: false,
|
||||
};
|
||||
definition.elements = newElements;
|
||||
selectedIds = [existing.id];
|
||||
leftTab = 'layers';
|
||||
} else if (widgetKind === 'launch-button') {
|
||||
let existing = newElements.find(
|
||||
(el) =>
|
||||
(el.type === 'widget' && el.widget === 'launch-button') ||
|
||||
(el.type === 'button' && el.action.type === 'launch')
|
||||
);
|
||||
if (!existing) {
|
||||
const launchBtn = createThemeV2Element(
|
||||
'widget',
|
||||
newElements.length
|
||||
) as ThemeV2WidgetElement;
|
||||
launchBtn.widget = 'launch-button';
|
||||
launchBtn.name = 'Launch Button';
|
||||
launchBtn.x = 740;
|
||||
launchBtn.y = 575;
|
||||
launchBtn.width = 210;
|
||||
launchBtn.height = 48;
|
||||
newElements.push(launchBtn);
|
||||
existing = launchBtn;
|
||||
}
|
||||
definition.chrome = {
|
||||
...currentChrome,
|
||||
layoutMode: 'custom',
|
||||
dock: false,
|
||||
};
|
||||
definition.elements = newElements;
|
||||
selectedIds = [existing.id];
|
||||
leftTab = 'layers';
|
||||
} else if (widgetKind === 'player-card') {
|
||||
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'player-card');
|
||||
if (!existing) {
|
||||
const playerCard = createThemeV2Element(
|
||||
'widget',
|
||||
newElements.length
|
||||
) as ThemeV2WidgetElement;
|
||||
playerCard.widget = 'player-card';
|
||||
playerCard.name = 'Player Card';
|
||||
playerCard.x = 100;
|
||||
playerCard.y = 150;
|
||||
playerCard.width = 380;
|
||||
playerCard.height = 230;
|
||||
newElements.push(playerCard);
|
||||
existing = playerCard;
|
||||
}
|
||||
definition.chrome = {
|
||||
...currentChrome,
|
||||
layoutMode: 'custom',
|
||||
playerCard: false,
|
||||
};
|
||||
definition.elements = newElements;
|
||||
selectedIds = [existing.id];
|
||||
leftTab = 'layers';
|
||||
} else if (widgetKind === 'avatar') {
|
||||
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'avatar');
|
||||
if (!existing) {
|
||||
const avatarWidget = createThemeV2Element(
|
||||
'widget',
|
||||
newElements.length
|
||||
) as ThemeV2WidgetElement;
|
||||
avatarWidget.widget = 'avatar';
|
||||
avatarWidget.name = 'Player Avatar';
|
||||
avatarWidget.x = 80;
|
||||
avatarWidget.y = 80;
|
||||
avatarWidget.width = 72;
|
||||
avatarWidget.height = 72;
|
||||
avatarWidget.variant = 'circle';
|
||||
avatarWidget.showCountry = true;
|
||||
avatarWidget.showLevel = true;
|
||||
avatarWidget.showOnlineRing = true;
|
||||
newElements.push(avatarWidget);
|
||||
existing = avatarWidget;
|
||||
}
|
||||
definition.chrome = {
|
||||
...currentChrome,
|
||||
layoutMode: 'custom',
|
||||
};
|
||||
definition.elements = newElements;
|
||||
selectedIds = [existing.id];
|
||||
leftTab = 'layers';
|
||||
} else if (widgetKind === 'recent-activity') {
|
||||
let existing = newElements.find(
|
||||
(el) => el.type === 'widget' && el.widget === 'recent-activity'
|
||||
);
|
||||
if (!existing) {
|
||||
const recentActivity = createThemeV2Element(
|
||||
'widget',
|
||||
newElements.length
|
||||
) as ThemeV2WidgetElement;
|
||||
recentActivity.widget = 'recent-activity';
|
||||
recentActivity.name = 'Recent Activity';
|
||||
recentActivity.x = 510;
|
||||
recentActivity.y = 150;
|
||||
recentActivity.width = 340;
|
||||
recentActivity.height = 230;
|
||||
newElements.push(recentActivity);
|
||||
existing = recentActivity;
|
||||
}
|
||||
definition.chrome = {
|
||||
...currentChrome,
|
||||
layoutMode: 'custom',
|
||||
recentActivity: false,
|
||||
};
|
||||
definition.elements = newElements;
|
||||
selectedIds = [existing.id];
|
||||
leftTab = 'layers';
|
||||
}
|
||||
}
|
||||
|
||||
function detachAllToCanvas() {
|
||||
handleDetachWidget('header');
|
||||
handleDetachWidget('launch-button');
|
||||
handleDetachWidget('player-card');
|
||||
handleDetachWidget('recent-activity');
|
||||
}
|
||||
|
||||
function restoreNativeDock() {
|
||||
const currentChrome = definition.chrome ?? normalizeChrome(undefined, definition.elements);
|
||||
definition.chrome = {
|
||||
...currentChrome,
|
||||
layoutMode: 'dashboard',
|
||||
dock: true,
|
||||
playerCard: true,
|
||||
recentActivity: true,
|
||||
header: true,
|
||||
};
|
||||
}
|
||||
|
||||
function handleTestButtonAction(action: ThemeV2ButtonAction) {
|
||||
if (action.type === 'navigate') {
|
||||
sileo.info({
|
||||
title: 'Button Action Test',
|
||||
description: `Navigates to screen "${action.screen}" in launcher.`,
|
||||
});
|
||||
} else if (action.type === 'launch') {
|
||||
sileo.info({
|
||||
title: 'Button Action Test',
|
||||
description: 'Launches osu! game.',
|
||||
});
|
||||
} else if (action.type === 'url') {
|
||||
if (action.url) {
|
||||
sileo.info({
|
||||
title: 'Button Action Test',
|
||||
description: `Opens ${action.url}`,
|
||||
});
|
||||
void openURL(action.url);
|
||||
} else {
|
||||
sileo.warning({
|
||||
title: 'Button Action Test',
|
||||
description: 'No URL specified',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addLottie() {
|
||||
const asset = await chooseAsset('lottie');
|
||||
if (!asset) return;
|
||||
@@ -2677,6 +2998,11 @@
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(definition.version.trim())) {
|
||||
warning('The version should use semantic versioning, for example 1.0.0.');
|
||||
}
|
||||
if (!themeHasLaunchButton(definition)) {
|
||||
error(
|
||||
'A launch button is required to launch osu!. Keep the bottom dock visible or add a launch button widget to your canvas.'
|
||||
);
|
||||
}
|
||||
|
||||
const background = definition.background;
|
||||
if ((background.type === 'image' || background.type === 'video') && !background.src.trim()) {
|
||||
@@ -3276,6 +3602,31 @@
|
||||
<Timer />
|
||||
Add countdown
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
class="gap-2 py-1.5 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'launch-button')}
|
||||
>
|
||||
<LayoutGrid />
|
||||
Add launch button widget
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
class="gap-2 py-1.5 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'player-card')}
|
||||
>
|
||||
<LayoutGrid />
|
||||
Add player card widget
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('widget', 'avatar')}>
|
||||
<CircleUser />
|
||||
Add player avatar widget
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
class="gap-2 py-1.5 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'recent-activity')}
|
||||
>
|
||||
<LayoutGrid />
|
||||
Add recent activity widget
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={addLottie}>
|
||||
<Film />
|
||||
Add Lottie animation
|
||||
@@ -3621,6 +3972,34 @@
|
||||
<Timer />
|
||||
Countdown
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'launch-button')}
|
||||
>
|
||||
<LayoutGrid />
|
||||
Launch Button Widget
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'player-card')}
|
||||
>
|
||||
<LayoutGrid />
|
||||
Player Card Widget
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'avatar')}
|
||||
>
|
||||
<CircleUser />
|
||||
Player Avatar Widget
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => addLayer('widget', 'recent-activity')}
|
||||
>
|
||||
<LayoutGrid />
|
||||
Recent Activity Widget
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={addLottie}>
|
||||
<Film />
|
||||
Lottie animation
|
||||
@@ -4500,6 +4879,191 @@
|
||||
onchange={setVariableDefault}
|
||||
/>
|
||||
|
||||
<ThemeSection title="Launcher Layout & Chrome" contentClass="space-y-3 pt-2.5">
|
||||
<div class="space-y-1.5">
|
||||
<Label class={labelClass}>Layout Preset</Label>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-md bg-black/30 p-1">
|
||||
{#each [{ value: 'dashboard', label: 'Dashboard' }, { value: 'classic', label: 'Classic' }, { value: 'custom', label: 'Custom' }] as mode (mode.value)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-1 text-[10px] {themeChrome.layoutMode === mode.value
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => setLayoutMode(mode.value as ThemeV2LayoutMode)}
|
||||
>
|
||||
{mode.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
{#if themeChrome.layoutMode === 'dashboard'}
|
||||
Modern dashboard with large player card and recent activity highlights.
|
||||
{:else if themeChrome.layoutMode === 'classic'}
|
||||
Original minimal layout with compact bottom-left stats and open canvas.
|
||||
{:else}
|
||||
Custom freeform canvas: default cards are hidden so your layers take center stage.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Freeform Moveable Elements Box -->
|
||||
<div class="space-y-2 rounded-lg border border-primary/30 bg-primary/10 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-theme-50">Moveable Canvas Elements</span>
|
||||
<Badge variant="outline" class="border-primary/40 text-[9px] text-primary"
|
||||
>Freeform</Badge
|
||||
>
|
||||
</div>
|
||||
<p class="text-[10px] leading-relaxed text-theme-200">
|
||||
Convert the Launch button, Player card, and Activity card into draggable layers so
|
||||
you can position them anywhere.
|
||||
</p>
|
||||
<div class="flex flex-col gap-1.5 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 bg-primary text-xs font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
onclick={detachAllToCanvas}
|
||||
>
|
||||
✨ Detach All to Canvas
|
||||
</Button>
|
||||
<div class="grid grid-cols-2 gap-1 pt-0.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => handleDetachWidget('header')}
|
||||
>
|
||||
Move Header Title
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => handleDetachWidget('launch-button')}
|
||||
>
|
||||
Move Launch Button
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => handleDetachWidget('player-card')}
|
||||
>
|
||||
Move Player Card
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => handleDetachWidget('avatar')}
|
||||
>
|
||||
Move Avatar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => handleDetachWidget('recent-activity')}
|
||||
>
|
||||
Move Activity Card
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-[10px] text-muted-foreground hover:text-white"
|
||||
onclick={restoreNativeDock}
|
||||
>
|
||||
Restore Dock
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 border-t border-theme-800 pt-2">
|
||||
<Label class="text-xs font-semibold text-theme-100">Component Visibility</Label>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
|
||||
>
|
||||
<span>Header Title & Greeting</span>
|
||||
<Checkbox
|
||||
checked={themeChrome.header !== false}
|
||||
onCheckedChange={(c) => updateChrome('header', c === true)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
|
||||
>
|
||||
<span>Player Card</span>
|
||||
<Checkbox
|
||||
checked={themeChrome.playerCard !== false}
|
||||
onCheckedChange={(c) => updateChrome('playerCard', c === true)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
|
||||
>
|
||||
<span>Recent Activity Card</span>
|
||||
<Checkbox
|
||||
checked={themeChrome.recentActivity !== false}
|
||||
onCheckedChange={(c) => updateChrome('recentActivity', c === true)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
|
||||
>
|
||||
<span>Bottom Dock</span>
|
||||
<Checkbox
|
||||
checked={themeChrome.dock !== false}
|
||||
onCheckedChange={(c) => updateChrome('dock', c === true)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
|
||||
>
|
||||
<span>Dock Telemetry Chips</span>
|
||||
<Checkbox
|
||||
checked={themeChrome.dockTelemetry !== false}
|
||||
onCheckedChange={(c) => updateChrome('dockTelemetry', c === true)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Launch Button Requirement Indicator -->
|
||||
<div
|
||||
class="rounded-md border p-2.5 text-[11px] {hasValidLaunchButton
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-amber-500/30 bg-amber-500/10 text-amber-300'}"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 font-bold">
|
||||
{#if hasValidLaunchButton}
|
||||
<CheckCircle2 class="size-3.5 text-emerald-400" />
|
||||
<span>Launch button satisfied</span>
|
||||
{:else}
|
||||
<AlertTriangle class="size-3.5 text-amber-400" />
|
||||
<span>Launch button required!</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 text-[10px] leading-relaxed opacity-85">
|
||||
{#if hasValidLaunchButton}
|
||||
Theme includes osu! launch capability ({themeChrome.dock !== false
|
||||
? 'via bottom dock'
|
||||
: 'via canvas launch widget'}).
|
||||
{:else}
|
||||
osu! requires a launch button. Turn on the Bottom Dock or add a Launch Button
|
||||
widget to the canvas.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeSection title="Background" contentClass="space-y-3 pt-2.5">
|
||||
<div class="grid grid-cols-4 gap-1 rounded-md bg-black/30 p-1">
|
||||
{#each backgroundTypes as option (option.value)}
|
||||
@@ -4619,6 +5183,11 @@
|
||||
definition.background.type === 'video'
|
||||
) {
|
||||
definition.background.blur = value;
|
||||
if (definition.background.type === 'image' && lastImageBackground) {
|
||||
lastImageBackground.blur = value;
|
||||
} else if (definition.background.type === 'video' && lastVideoBackground) {
|
||||
lastVideoBackground.blur = value;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -4632,7 +5201,13 @@
|
||||
definition.background.type === 'image' ||
|
||||
definition.background.type === 'video'
|
||||
) {
|
||||
definition.background.fit = checked ? 'contain' : 'cover';
|
||||
const fitVal = checked ? 'contain' : 'cover';
|
||||
definition.background.fit = fitVal;
|
||||
if (definition.background.type === 'image' && lastImageBackground) {
|
||||
lastImageBackground.fit = fitVal;
|
||||
} else if (definition.background.type === 'video' && lastVideoBackground) {
|
||||
lastVideoBackground.fit = fitVal;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -4651,6 +5226,9 @@
|
||||
onCheckedChange={(checked) => {
|
||||
if (definition.background.type === 'video') {
|
||||
definition.background.muted = checked === true;
|
||||
if (lastVideoBackground) {
|
||||
lastVideoBackground.muted = checked === true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -5157,6 +5735,8 @@
|
||||
onpatchgroup={(id, patch) => patchGroup(id, patch)}
|
||||
oncontext={(id) => prepareContextMenu(id)}
|
||||
oncontextgroup={(id) => prepareGroupContextMenu(id)}
|
||||
ondetach={handleDetachWidget}
|
||||
onbuttonaction={handleTestButtonAction}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
@@ -5605,6 +6185,38 @@
|
||||
<Film class="size-4 text-primary" />
|
||||
Lottie
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-auto flex-col gap-1 p-3 text-[10px]"
|
||||
onclick={() => addLayer('widget', 'launch-button')}
|
||||
>
|
||||
<LayoutGrid class="size-4 text-primary" />
|
||||
Launch Button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-auto flex-col gap-1 p-3 text-[10px]"
|
||||
onclick={() => addLayer('widget', 'player-card')}
|
||||
>
|
||||
<LayoutGrid class="size-4 text-primary" />
|
||||
Player Card
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-auto flex-col gap-1 p-3 text-[10px]"
|
||||
onclick={() => addLayer('widget', 'avatar')}
|
||||
>
|
||||
<CircleUser class="size-4 text-primary" />
|
||||
Player Avatar
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-auto flex-col gap-1 p-3 text-[10px]"
|
||||
onclick={() => addLayer('widget', 'recent-activity')}
|
||||
>
|
||||
<LayoutGrid class="size-4 text-primary" />
|
||||
Recent Activity
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ThemeSection
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
||||
import type { ThemeFontChoice } from '$lib/themeFonts';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||
import { openURL } from '$lib/utils';
|
||||
import { sileo } from 'sileo';
|
||||
import {
|
||||
THEME_DATA_FIELDS,
|
||||
THEME_PARTICLE_PRESETS,
|
||||
@@ -27,15 +29,20 @@
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2MaskShape,
|
||||
type ThemeV2WidgetKind,
|
||||
type ThemeV2WidgetVariant,
|
||||
} from '$lib/themeV2';
|
||||
import {
|
||||
CircleUser,
|
||||
Clock,
|
||||
Component,
|
||||
Film,
|
||||
Gauge,
|
||||
Image as ImageIcon,
|
||||
LayoutGrid,
|
||||
Megaphone,
|
||||
MousePointerClick,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Shapes,
|
||||
@@ -198,6 +205,12 @@
|
||||
<Clock class="size-4" />
|
||||
{:else if element.type === 'countdown'}
|
||||
<Timer class="size-4" />
|
||||
{:else if element.type === 'widget'}
|
||||
{#if element.widget === 'avatar'}
|
||||
<CircleUser class="size-4" />
|
||||
{:else}
|
||||
<LayoutGrid class="size-4" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ImageIcon class="size-4" />
|
||||
{/if}
|
||||
@@ -1593,6 +1606,46 @@
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1.5 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full gap-2 border-theme-700 bg-theme-900/60 text-xs text-theme-100 hover:bg-theme-800"
|
||||
onclick={() => {
|
||||
if (element.action.type === 'navigate') {
|
||||
sileo.info({
|
||||
title: 'Button Action Test',
|
||||
description: `Navigates to screen "${element.action.screen}"`,
|
||||
});
|
||||
} else if (element.action.type === 'launch') {
|
||||
sileo.info({
|
||||
title: 'Button Action Test',
|
||||
description: 'Launches osu!',
|
||||
});
|
||||
} else if (element.action.type === 'url') {
|
||||
if (element.action.url) {
|
||||
sileo.info({
|
||||
title: 'Button Action Test',
|
||||
description: `Opens ${element.action.url}`,
|
||||
});
|
||||
void openURL(element.action.url);
|
||||
} else {
|
||||
sileo.warning({
|
||||
title: 'Button Action Test',
|
||||
description: 'No URL specified',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play class="size-3.5 text-primary" />
|
||||
Test Button Action
|
||||
</Button>
|
||||
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||
In editor mode, canvas clicks select layers for dragging. Test actions using this button, or Shift+click on canvas.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-button-icon" class={label}>Icon</Label>
|
||||
@@ -2165,6 +2218,296 @@
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
{:else if element.type === 'widget'}
|
||||
<ThemeSection title="Widget" contentClass="space-y-3 pt-2.5">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="element-widget-type" class={label}>Widget Type</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.widget}
|
||||
onValueChange={(val) => {
|
||||
if (val) {
|
||||
element.widget = val as ThemeV2WidgetKind;
|
||||
if (val === 'player-card') {
|
||||
element.width = 380;
|
||||
element.height = 180;
|
||||
} else if (val === 'avatar') {
|
||||
element.width = 72;
|
||||
element.height = 72;
|
||||
if (!element.variant) element.variant = 'circle';
|
||||
} else if (val === 'recent-activity') {
|
||||
element.width = 320;
|
||||
element.height = 180;
|
||||
} else if (val === 'telemetry') {
|
||||
element.width = 320;
|
||||
element.height = 42;
|
||||
} else if (val === 'header') {
|
||||
element.width = 320;
|
||||
element.height = 64;
|
||||
} else if (val === 'launch-button') {
|
||||
element.width = 200;
|
||||
element.height = 52;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-widget-type"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.widget === 'launch-button'
|
||||
? 'Launch Button'
|
||||
: element.widget === 'player-card'
|
||||
? 'Player Card'
|
||||
: element.widget === 'avatar'
|
||||
? 'Player Avatar'
|
||||
: element.widget === 'recent-activity'
|
||||
? 'Recent Activity'
|
||||
: element.widget === 'telemetry'
|
||||
? 'Telemetry Bar'
|
||||
: 'Header Title'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="launch-button" label="Launch Button" />
|
||||
<Select.Item value="player-card" label="Player Card" />
|
||||
<Select.Item value="avatar" label="Player Avatar" />
|
||||
<Select.Item value="recent-activity" label="Recent Activity" />
|
||||
<Select.Item value="telemetry" label="Telemetry Bar" />
|
||||
<Select.Item value="header" label="Header Title" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="element-widget-variant" class={label}>Variant</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.variant ??
|
||||
(element.widget === 'avatar'
|
||||
? 'circle'
|
||||
: element.widget === 'launch-button'
|
||||
? 'primary'
|
||||
: 'full')}
|
||||
onValueChange={(val) => {
|
||||
if (val) {
|
||||
element.variant = val as ThemeV2WidgetVariant;
|
||||
if (element.widget === 'player-card') {
|
||||
if (val === 'minimal') {
|
||||
element.width = 280;
|
||||
element.height = 64;
|
||||
} else if (val === 'compact') {
|
||||
element.width = 340;
|
||||
element.height = 120;
|
||||
} else if (val === 'full') {
|
||||
element.width = 380;
|
||||
element.height = 200;
|
||||
}
|
||||
} else if (element.widget === 'recent-activity') {
|
||||
if (val === 'minimal') {
|
||||
element.width = 280;
|
||||
element.height = 64;
|
||||
} else if (val === 'compact') {
|
||||
element.width = 340;
|
||||
element.height = 120;
|
||||
} else if (val === 'full') {
|
||||
element.width = 340;
|
||||
element.height = 200;
|
||||
}
|
||||
} else if (element.widget === 'launch-button') {
|
||||
if (val === 'icon-only') {
|
||||
element.width = 48;
|
||||
element.height = 48;
|
||||
} else if (val === 'compact') {
|
||||
element.width = 140;
|
||||
element.height = 42;
|
||||
} else if (val === 'primary') {
|
||||
element.width = 210;
|
||||
element.height = 48;
|
||||
}
|
||||
} else if (element.widget === 'header') {
|
||||
if (val === 'minimal') {
|
||||
element.width = 240;
|
||||
element.height = 42;
|
||||
} else if (val === 'compact') {
|
||||
element.width = 380;
|
||||
element.height = 48;
|
||||
} else if (val === 'full') {
|
||||
element.width = 320;
|
||||
element.height = 64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-widget-variant"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{#if element.widget === 'avatar'}
|
||||
{element.variant === 'rounded'
|
||||
? 'Rounded Square'
|
||||
: element.variant === 'square'
|
||||
? 'Square'
|
||||
: 'Circle'}
|
||||
{:else if element.widget === 'launch-button'}
|
||||
{element.variant === 'compact'
|
||||
? 'Compact (Pill)'
|
||||
: element.variant === 'icon-only'
|
||||
? 'Icon-Only (Circle)'
|
||||
: 'Primary (Capsule)'}
|
||||
{:else}
|
||||
{element.variant === 'compact'
|
||||
? 'Compact'
|
||||
: element.variant === 'minimal' || element.variant === 'icon-only'
|
||||
? 'Minimal'
|
||||
: 'Full'}
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
{#if element.widget === 'avatar'}
|
||||
<Select.Item value="circle" label="Circle" />
|
||||
<Select.Item value="rounded" label="Rounded Square" />
|
||||
<Select.Item value="square" label="Square" />
|
||||
{:else if element.widget === 'launch-button'}
|
||||
<Select.Item value="primary" label="Primary (Capsule)" />
|
||||
<Select.Item value="compact" label="Compact (Pill)" />
|
||||
<Select.Item value="icon-only" label="Icon-Only (Circle)" />
|
||||
{:else}
|
||||
<Select.Item value="full" label="Full" />
|
||||
<Select.Item value="compact" label="Compact" />
|
||||
<Select.Item value="minimal" label="Minimal" />
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if element.widget === 'avatar'}
|
||||
<div class="space-y-2 border-t border-theme-800 pt-2">
|
||||
<Label class={label}>Avatar Display Options</Label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="element-avatar-country"
|
||||
checked={element.showCountry !== false}
|
||||
onCheckedChange={(checked) => (element.showCountry = checked === true)}
|
||||
/>
|
||||
<Label
|
||||
for="element-avatar-country"
|
||||
class="text-[11px] font-normal text-muted-foreground"
|
||||
>
|
||||
Show Country Flag Badge
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="element-avatar-level"
|
||||
checked={element.showLevel !== false}
|
||||
onCheckedChange={(checked) => (element.showLevel = checked === true)}
|
||||
/>
|
||||
<Label
|
||||
for="element-avatar-level"
|
||||
class="text-[11px] font-normal text-muted-foreground"
|
||||
>
|
||||
Show Level Badge
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="element-avatar-ring"
|
||||
checked={element.showOnlineRing !== false}
|
||||
onCheckedChange={(checked) => (element.showOnlineRing = checked === true)}
|
||||
/>
|
||||
<Label
|
||||
for="element-avatar-ring"
|
||||
class="text-[11px] font-normal text-muted-foreground"
|
||||
>
|
||||
Online Status Glow Ring
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Snap Position Presets for Widgets -->
|
||||
<div class="space-y-1.5 border-t border-theme-800 pt-2">
|
||||
<Label class={label}>Quick Position Snaps</Label>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => {
|
||||
element.x = 1000 - element.width - 24;
|
||||
element.y = 650 - element.height - 24;
|
||||
}}
|
||||
>
|
||||
↘ Bottom-Right
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => {
|
||||
element.x = Math.round((1000 - element.width) / 2);
|
||||
element.y = 650 - element.height - 24;
|
||||
}}
|
||||
>
|
||||
↓ Bottom-Center
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => {
|
||||
element.x = 24;
|
||||
element.y = 650 - element.height - 24;
|
||||
}}
|
||||
>
|
||||
↙ Bottom-Left
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => {
|
||||
element.x = 1000 - element.width - 24;
|
||||
element.y = 24;
|
||||
}}
|
||||
>
|
||||
↗ Top-Right
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="col-span-2 h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
|
||||
onclick={() => {
|
||||
element.x = Math.round((1000 - element.width) / 2);
|
||||
element.y = Math.round((650 - element.height) / 2);
|
||||
}}
|
||||
>
|
||||
⊙ Center on Canvas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||
{#if element.widget === 'launch-button'}
|
||||
Interactive game launch button. Satisfies the required Launch button condition.
|
||||
{:else if element.widget === 'player-card'}
|
||||
Live stats card displaying rank, pp, accuracy, level, and playcount.
|
||||
{:else if element.widget === 'avatar'}
|
||||
Player avatar with live profile image, shape styles, country badge, and online ring.
|
||||
{:else if element.widget === 'recent-activity'}
|
||||
Highlights the player's latest play, beatmap cover art, and grade badge.
|
||||
{:else if element.widget === 'telemetry'}
|
||||
Pill bar showing beatmap counts, skin counts, ping, and osu! build.
|
||||
{:else if element.widget === 'header'}
|
||||
Launcher title and dynamic greeting. Full shows title and subtitle, Compact shows an inline pill greeting, and Minimal displays title only.
|
||||
{:else}
|
||||
Launcher title and dynamic greeting.
|
||||
{/if}
|
||||
</p>
|
||||
</ThemeSection>
|
||||
{/if}
|
||||
|
||||
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { currentUserInfo } from '$lib/data';
|
||||
import { scopeThemeCss } from '$lib/themeCssScope';
|
||||
import { fontStack, monoFontStack } from '$lib/themeFonts';
|
||||
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
|
||||
import {
|
||||
normalizeChrome,
|
||||
readableTextColor,
|
||||
themeFontFaceCss,
|
||||
themeHasLaunchButton,
|
||||
type ThemeV2ButtonAction,
|
||||
type ThemeV2Definition,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2ScriptValue,
|
||||
type ThemeV2WidgetKind,
|
||||
} from '$lib/themeV2';
|
||||
import { currentUser } from '$lib/userAuthentication';
|
||||
import { countryCodeToEmoji } from '$lib/utils';
|
||||
import { Clock, Trophy } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
definition,
|
||||
@@ -31,6 +40,8 @@
|
||||
onpatchgroup,
|
||||
oncontext,
|
||||
oncontextgroup,
|
||||
ondetach,
|
||||
onbuttonaction,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
@@ -48,6 +59,8 @@
|
||||
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
|
||||
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||
oncontextgroup?: (id: string, event: MouseEvent) => void;
|
||||
ondetach?: (widgetKind: ThemeV2WidgetKind) => void;
|
||||
onbuttonaction?: (action: ThemeV2ButtonAction) => void;
|
||||
} = $props();
|
||||
|
||||
const bodyFont = $derived(definition.typography?.body);
|
||||
@@ -56,6 +69,8 @@
|
||||
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
|
||||
const accentText = $derived(readableTextColor(accent));
|
||||
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
|
||||
const chrome = $derived(definition.chrome ?? normalizeChrome(undefined, definition.elements));
|
||||
const hasLaunchBtn = $derived(themeHasLaunchButton(definition));
|
||||
const stageStyle = $derived(
|
||||
[
|
||||
`font-family:${fontStack(bodyFont)}`,
|
||||
@@ -124,94 +139,283 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="relative flex min-w-0 flex-col">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="mt-12 mb-auto flex flex-col px-6">
|
||||
<span
|
||||
class="text-4xl font-bold drop-shadow-lg"
|
||||
style="font-family:{fontStack(headingFont, bodyFont)}"
|
||||
>
|
||||
EZPPLauncher
|
||||
</span>
|
||||
<span class="font-semibold text-muted-foreground drop-shadow-lg">Hello Guest!</span>
|
||||
</div>
|
||||
|
||||
<div class="relative h-50 shrink-0">
|
||||
<section class="relative flex min-w-0 flex-col justify-between">
|
||||
{#if chrome.header !== false}
|
||||
<div class="px-8 pt-7 pb-2 shrink-0 z-10 flex items-start justify-between gap-4">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="absolute left-0 top-0 m-1 flex h-48 w-72 flex-col items-center justify-center gap-3 rounded-md bg-black/40 p-3 ring-1 ring-inset ring-white/5 backdrop-blur-sm"
|
||||
class="group/header relative flex flex-col {interactive
|
||||
? 'cursor-pointer rounded-xl p-2 -m-2 transition-all hover:bg-white/5 hover:ring-2 hover:ring-primary/40'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('header');
|
||||
}
|
||||
}}
|
||||
title={interactive ? 'Click to detach into moveable Header Title layer' : undefined}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Log in with your EZPPFarm account to track your rank, PP, accuracy and playcount.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<span
|
||||
class="text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
|
||||
style="font-family:{fontStack(headingFont, bodyFont)}"
|
||||
>
|
||||
EZPPLauncher
|
||||
</span>
|
||||
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
|
||||
Good evening, Guest!
|
||||
</span>
|
||||
{#if interactive}
|
||||
<div
|
||||
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
|
||||
class="pointer-events-none absolute -bottom-6 left-0 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/header:opacity-100"
|
||||
>
|
||||
Log In
|
||||
Click to detach & move freely
|
||||
</div>
|
||||
<div
|
||||
class="flex h-7 w-full items-center justify-center rounded-md border border-theme-800 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Register
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<div class="px-8 py-2 my-auto z-10">
|
||||
{#if chrome.layoutMode === 'dashboard'}
|
||||
<div class="flex flex-row items-stretch gap-5 w-full max-w-4xl">
|
||||
{#if chrome.playerCard !== false}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="flex-1 min-w-[320px] max-w-[440px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-4 flex flex-col justify-between gap-3 shadow-xl {interactive
|
||||
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('player-card');
|
||||
}
|
||||
}}
|
||||
title={interactive ? 'Click to detach into moveable Player Card layer' : undefined}
|
||||
>
|
||||
<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/{$currentUser?.id ??
|
||||
$currentUserInfo?.info?.id ??
|
||||
0}"
|
||||
alt={$currentUser?.name ?? 'Player'}
|
||||
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 $currentUserInfo?.info?.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"
|
||||
>
|
||||
{countryCodeToEmoji($currentUserInfo.info.country)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold text-sm text-theme-50 block"
|
||||
>{$currentUser?.name ?? 'Guest Player'}</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground block">
|
||||
Lv.{$currentUserInfo?.stats?.['0']?.level ?? 98} · {(
|
||||
$currentUserInfo?.stats?.['0']?.acc ?? 93
|
||||
).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="bg-white/5 rounded-xl p-2.5">
|
||||
<span class="text-[10px] text-muted-foreground block">Rank</span>
|
||||
<span class="font-bold text-sm text-theme-50">#102</span>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-2.5">
|
||||
<span class="text-[10px] text-muted-foreground block">Performance</span>
|
||||
<span class="font-bold text-sm text-primary">4,521pp</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chrome.recentActivity !== false}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="flex-1 min-w-[300px] max-w-[380px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden shadow-xl flex flex-col justify-between {interactive
|
||||
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('recent-activity');
|
||||
}
|
||||
}}
|
||||
title={interactive
|
||||
? 'Click to detach into moveable Recent Activity layer'
|
||||
: undefined}
|
||||
>
|
||||
<!-- Mini Cover Header -->
|
||||
<div class="relative h-20 w-full shrink-0 overflow-hidden bg-theme-900/80">
|
||||
<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"
|
||||
>
|
||||
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">Space Pirates</span>
|
||||
<span class="truncate text-[10px] text-white/70">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">
|
||||
[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 #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" />
|
||||
5d ago
|
||||
</span>
|
||||
<span class="text-white/60">ez-pp.farm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if chrome.layoutMode === 'classic'}
|
||||
{#if chrome.playerCard !== false}
|
||||
<div class="flex flex-col justify-end">
|
||||
<div
|
||||
class="w-72 h-48 bg-black/40 backdrop-blur-sm rounded-md ring-1 ring-inset ring-white/5 flex flex-col items-center justify-center p-3 gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Log in with your EZPPFarm account to track your rank.</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div
|
||||
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
|
||||
>
|
||||
Log In
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-4 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">1.2k</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">42</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">19ms</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-white/10"></div>
|
||||
<span
|
||||
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 text-[10px] text-white/50"
|
||||
>
|
||||
stable
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/50"
|
||||
>
|
||||
20260101.0
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border border-primary/20 bg-primary/20 px-2 py-0.5 text-[10px] text-primary"
|
||||
>
|
||||
Default skin
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-12 items-center rounded-full bg-primary shadow-lg shadow-primary/25">
|
||||
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
||||
<div
|
||||
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
|
||||
>
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={iconSet}
|
||||
class="ml-0.5 size-4 fill-current text-primary-foreground"
|
||||
/>
|
||||
{#if chrome.dock !== false}
|
||||
<div
|
||||
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-3.5 backdrop-blur-sm shrink-0"
|
||||
>
|
||||
{#if chrome.dockTelemetry !== false}
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
|
||||
>
|
||||
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
|
||||
<span>1.2k</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
|
||||
>
|
||||
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
|
||||
<span>42</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
|
||||
>
|
||||
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
|
||||
<span>19ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-52 pl-1 pr-6 text-center">
|
||||
<p class="text-sm font-bold leading-tight text-primary-foreground">Launch</p>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="group/launch relative flex h-11 items-center rounded-full bg-primary shadow-lg shadow-primary/25 {interactive
|
||||
? 'cursor-pointer hover:ring-2 hover:ring-white/40 transition-all'
|
||||
: ''}"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : undefined}
|
||||
onclick={(e) => {
|
||||
if (interactive) {
|
||||
e.stopPropagation();
|
||||
ondetach?.('launch-button');
|
||||
}
|
||||
}}
|
||||
title={interactive
|
||||
? 'Click to detach and position Launch Button freely anywhere'
|
||||
: undefined}
|
||||
>
|
||||
<div class="relative flex size-11 shrink-0 items-center justify-center">
|
||||
<div
|
||||
class="relative z-10 flex size-8 items-center justify-center rounded-full bg-black/30"
|
||||
>
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={iconSet}
|
||||
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-48 pl-1 pr-5 text-center">
|
||||
<p class="text-xs font-bold leading-tight text-primary-foreground">Launch</p>
|
||||
</div>
|
||||
{#if interactive}
|
||||
<div
|
||||
class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/launch:opacity-100"
|
||||
>
|
||||
Click to detach & move freely
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !hasLaunchBtn}
|
||||
<div
|
||||
class="fixed bottom-6 right-6 z-30 flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-300 backdrop-blur"
|
||||
>
|
||||
<span>⚠️ Launch Button Missing</span>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -238,5 +442,6 @@
|
||||
{onpatchgroup}
|
||||
{oncontext}
|
||||
{oncontextgroup}
|
||||
{onbuttonaction}
|
||||
/>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -26,6 +26,24 @@ export const themeElementLabel = (element: ThemeV2Element): string => {
|
||||
if (element.type === 'countdown')
|
||||
return element.target ? `Countdown · ${element.target}` : 'Countdown';
|
||||
if (element.type === 'svelte') return 'Svelte component';
|
||||
if (element.type === 'widget') {
|
||||
switch (element.widget) {
|
||||
case 'launch-button':
|
||||
return 'Launch button widget';
|
||||
case 'player-card':
|
||||
return 'Player card widget';
|
||||
case 'recent-activity':
|
||||
return 'Recent activity widget';
|
||||
case 'telemetry':
|
||||
return 'Telemetry bar widget';
|
||||
case 'header':
|
||||
return 'Header widget';
|
||||
case 'avatar':
|
||||
return 'Player avatar widget';
|
||||
default:
|
||||
return 'Widget';
|
||||
}
|
||||
}
|
||||
return element.src.split('/').pop() || element.type;
|
||||
};
|
||||
|
||||
|
||||
+918
-49
File diff suppressed because it is too large.
Load diff
+163
-3
@@ -510,6 +510,33 @@ export type ThemeV2SvelteElement = ThemeV2ElementBase & {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ThemeV2WidgetKind =
|
||||
| 'player-card'
|
||||
| 'recent-activity'
|
||||
| 'launch-button'
|
||||
| 'telemetry'
|
||||
| 'header'
|
||||
| 'avatar';
|
||||
|
||||
export type ThemeV2WidgetVariant =
|
||||
| 'primary'
|
||||
| 'full'
|
||||
| 'compact'
|
||||
| 'minimal'
|
||||
| 'icon-only'
|
||||
| 'circle'
|
||||
| 'rounded'
|
||||
| 'square';
|
||||
|
||||
export type ThemeV2WidgetElement = ThemeV2ElementBase & {
|
||||
type: 'widget';
|
||||
widget: ThemeV2WidgetKind;
|
||||
variant?: ThemeV2WidgetVariant;
|
||||
showCountry?: boolean;
|
||||
showLevel?: boolean;
|
||||
showOnlineRing?: boolean;
|
||||
};
|
||||
|
||||
export type ThemeV2Element =
|
||||
| ThemeV2TextElement
|
||||
| ThemeV2DataElement
|
||||
@@ -526,7 +553,20 @@ export type ThemeV2Element =
|
||||
| ThemeV2ParticlesElement
|
||||
| ThemeV2ClockElement
|
||||
| ThemeV2CountdownElement
|
||||
| ThemeV2SvelteElement;
|
||||
| ThemeV2SvelteElement
|
||||
| ThemeV2WidgetElement;
|
||||
|
||||
export type ThemeV2LayoutMode = 'dashboard' | 'classic' | 'custom';
|
||||
|
||||
export type ThemeV2Chrome = {
|
||||
layoutMode?: ThemeV2LayoutMode;
|
||||
header?: boolean;
|
||||
pills?: boolean;
|
||||
playerCard?: boolean;
|
||||
recentActivity?: boolean;
|
||||
dock?: boolean;
|
||||
dockTelemetry?: boolean;
|
||||
};
|
||||
|
||||
export type ThemeV2ScriptScreen = 'home' | 'themes' | 'settings' | 'login';
|
||||
export type ThemeV2ScriptValue = string | number | boolean;
|
||||
@@ -624,6 +664,8 @@ export type ThemeV2Definition = {
|
||||
accentColor?: string;
|
||||
/** Optional CSS appended after the launcher's own styles. */
|
||||
css?: string;
|
||||
/** Launcher page layout mode and visibility of chrome components. */
|
||||
chrome?: ThemeV2Chrome;
|
||||
};
|
||||
|
||||
export type ThemeV2Asset = {
|
||||
@@ -912,6 +954,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
||||
'clock',
|
||||
'countdown',
|
||||
'svelte',
|
||||
'widget',
|
||||
] as const,
|
||||
'text'
|
||||
);
|
||||
@@ -932,7 +975,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
||||
opacity: clamp(source.opacity, 1, 0, 1),
|
||||
hidden: source.hidden === true,
|
||||
locked: source.locked === true,
|
||||
interactive: source.interactive === true,
|
||||
interactive: source.interactive === true || type === 'button' || type === 'widget',
|
||||
groupId: typeof source.groupId === 'string' ? source.groupId.slice(0, 64) : undefined,
|
||||
filters: normalizeFilters(source.filters),
|
||||
mask: normalizeMask(source.mask),
|
||||
@@ -1190,6 +1233,41 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'widget') {
|
||||
const widget = oneOf(
|
||||
source.widget,
|
||||
['player-card', 'recent-activity', 'launch-button', 'telemetry', 'header', 'avatar'] as const,
|
||||
'launch-button'
|
||||
);
|
||||
const variant = oneOf(
|
||||
source.variant,
|
||||
[
|
||||
'primary',
|
||||
'full',
|
||||
'compact',
|
||||
'minimal',
|
||||
'icon-only',
|
||||
'circle',
|
||||
'rounded',
|
||||
'square',
|
||||
] as const,
|
||||
widget === 'launch-button' ? 'primary' : widget === 'avatar' ? 'circle' : 'full'
|
||||
);
|
||||
const showCountry = typeof source.showCountry === 'boolean' ? source.showCountry : true;
|
||||
const showLevel = typeof source.showLevel === 'boolean' ? source.showLevel : false;
|
||||
const showOnlineRing =
|
||||
typeof source.showOnlineRing === 'boolean' ? source.showOnlineRing : true;
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
widget,
|
||||
variant,
|
||||
showCountry,
|
||||
showLevel,
|
||||
showOnlineRing,
|
||||
};
|
||||
}
|
||||
|
||||
const src = text(source.src).slice(0, 500);
|
||||
if (!src) return undefined;
|
||||
return {
|
||||
@@ -1518,7 +1596,15 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
|
||||
}
|
||||
if (type === 'button') {
|
||||
return normalizeElement(
|
||||
{ ...base, type, label: 'Open themes', width: 220, height: 52, y: 320 },
|
||||
{
|
||||
...base,
|
||||
type,
|
||||
label: 'Open themes',
|
||||
action: { type: 'navigate', screen: 'themes' },
|
||||
width: 220,
|
||||
height: 52,
|
||||
y: 320,
|
||||
},
|
||||
index
|
||||
)! as ThemeV2ButtonElement;
|
||||
}
|
||||
@@ -1572,6 +1658,18 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
|
||||
index
|
||||
)! as ThemeV2SvelteElement;
|
||||
}
|
||||
if (type === 'widget') {
|
||||
return normalizeElement(
|
||||
{
|
||||
...base,
|
||||
type,
|
||||
widget: 'launch-button',
|
||||
width: 220,
|
||||
height: 60,
|
||||
},
|
||||
index
|
||||
)! as ThemeV2WidgetElement;
|
||||
}
|
||||
return normalizeElement(
|
||||
{ ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 },
|
||||
index
|
||||
@@ -1581,6 +1679,58 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
|
||||
export const isThemeV2 = (apiVersion: unknown) =>
|
||||
typeof apiVersion === 'string' && apiVersion.split('.')[0] === '2';
|
||||
|
||||
export const normalizeChrome = (raw: unknown, elements: ThemeV2Element[] = []): ThemeV2Chrome => {
|
||||
const hasCustomElements = elements.length > 0;
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
if (hasCustomElements) {
|
||||
return {
|
||||
layoutMode: 'classic',
|
||||
header: true,
|
||||
pills: false,
|
||||
playerCard: true,
|
||||
recentActivity: false,
|
||||
dock: true,
|
||||
dockTelemetry: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
layoutMode: 'dashboard',
|
||||
header: true,
|
||||
pills: true,
|
||||
playerCard: true,
|
||||
recentActivity: true,
|
||||
dock: true,
|
||||
dockTelemetry: true,
|
||||
};
|
||||
}
|
||||
const source = raw as Record<string, unknown>;
|
||||
const layoutMode = oneOf(
|
||||
source.layoutMode,
|
||||
['dashboard', 'classic', 'custom'] as const,
|
||||
hasCustomElements ? 'classic' : 'dashboard'
|
||||
);
|
||||
return {
|
||||
layoutMode,
|
||||
header: source.header !== false,
|
||||
pills: source.pills !== false,
|
||||
playerCard: source.playerCard !== false,
|
||||
recentActivity: source.recentActivity !== false,
|
||||
dock: source.dock !== false,
|
||||
dockTelemetry: source.dockTelemetry !== false,
|
||||
};
|
||||
};
|
||||
|
||||
export const themeHasLaunchButton = (
|
||||
definition: Pick<ThemeV2Definition, 'elements' | 'chrome'>
|
||||
): boolean => {
|
||||
if (definition.chrome?.dock !== false) return true;
|
||||
return definition.elements.some(
|
||||
(el) =>
|
||||
(el.type === 'widget' && el.widget === 'launch-button') ||
|
||||
(el.type === 'button' && el.action.type === 'launch')
|
||||
);
|
||||
};
|
||||
|
||||
export const createDefaultThemeV2 = (): ThemeV2Definition => ({
|
||||
name: 'Untitled theme',
|
||||
version: '1.0.0',
|
||||
@@ -1617,6 +1767,15 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({
|
||||
behaviors: [],
|
||||
accentColor: '#ff006e',
|
||||
css: '',
|
||||
chrome: {
|
||||
layoutMode: 'dashboard',
|
||||
header: true,
|
||||
pills: true,
|
||||
playerCard: true,
|
||||
recentActivity: true,
|
||||
dock: true,
|
||||
dockTelemetry: true,
|
||||
},
|
||||
});
|
||||
|
||||
/** Coerce a theme.json payload into the supported v2 shape. */
|
||||
@@ -1731,6 +1890,7 @@ export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Defini
|
||||
variables: normalizeVariableDefaults(raw.variables),
|
||||
accentColor: color(raw.accentColor, '#ff006e'),
|
||||
css: typeof raw.css === 'string' ? raw.css : '',
|
||||
chrome: normalizeChrome(raw.chrome, elements),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,32 @@ export const formatTimeReadable = (initialSeconds: number) => {
|
||||
return result.trim();
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (dateOrTimestamp: Date | string | number | undefined): string => {
|
||||
if (!dateOrTimestamp) return 'recently';
|
||||
const time = new Date(dateOrTimestamp).getTime();
|
||||
if (isNaN(time)) return 'recently';
|
||||
const diff = Date.now() - time;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
};
|
||||
|
||||
export const countryCodeToEmoji = (code?: string): string => {
|
||||
if (!code || code.length !== 2) return '🌐';
|
||||
const codePoints = code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => 127397 + c.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
export const releaseStreamToReadable = (releaseStream: string) => {
|
||||
if (releaseStream.replaceAll(' ', '').toLowerCase() === 'cuttingedge') return 'Cutting Edge';
|
||||
return 'Stable';
|
||||
|
||||
Reference in new issue
Block a user