Files
EZPPLauncher/src/lib/components/presence/DiscordPresenceEditor.svelte
T

1813 lines
77 KiB
Svelte

<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { currentUserInfo } from '$lib/data';
import {
DEFAULT_DISCORD_PRESENCE_CONFIG,
DISCORD_PRESENCE_PRESETS,
interpolatePresenceString,
loadDiscordPresenceConfig,
pushIdlePresenceFromConfig,
saveDiscordPresenceConfig,
type DiscordPresenceConfig,
type PresenceVariableContext,
} from '$lib/discordPresenceConfig';
import { discordPresence, launcherVersion } from '$lib/global';
import * as presence from '$lib/presence';
import { currentUser } from '$lib/userAuthentication';
import {
Check,
Clock,
Code2,
ExternalLink,
Gamepad2,
Globe,
Home,
Image as ImageIcon,
Link,
LoaderCircle,
Maximize2,
Minus,
MousePointerClick,
Play,
Plus,
RotateCcw,
Sparkles,
Type,
X,
} from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { sileo } from 'sileo';
import { onDestroy, onMount } from 'svelte';
import { fade, scale, slide } from 'svelte/transition';
let {
open = $bindable(false),
standalone = false,
onclose,
}: {
open?: boolean;
standalone?: boolean;
onclose?: () => void;
} = $props();
// Active studio tab: 'idle' | 'playing' | 'buttons' | 'presets'
let activeTab = $state<'idle' | 'playing' | 'buttons' | 'presets'>('idle');
// Preview stage mode: 'idle' | 'playing'
let previewMode = $state<'idle' | 'playing'>('idle');
// Card interaction mode: 'edit' (clicking elements focuses input) | 'test' (clicking tests buttons)
let cardInteractionMode = $state<'edit' | 'test'>('edit');
// Canvas zoom scale (0.7 to 1.4)
let canvasScale = $state<number>(1);
// Working copy of config
let workingConfig = $state<DiscordPresenceConfig>({
...DEFAULT_DISCORD_PRESENCE_CONFIG,
idleButton1: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.idleButton1 },
playingButton1: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.playingButton1 },
button2: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.button2 },
});
// Track initial snapshot for dirty state
let initialConfigJson = $state<string>('');
let isDirty = $derived(
initialConfigJson !== '' && JSON.stringify(workingConfig) !== initialConfigJson
);
// Saving state
let isSaving = $state(false);
// Track currently focused input field for placeholder chip insertions.
// When no input is actively clicked/focused, this is null and the ribbon is hidden.
let focusedInputKey = $state<string | null>(null);
let blurTimeout: ReturnType<typeof setTimeout> | null = null;
// Preset selector
let selectedPresetName = $state<string>('EZPPFarm Default');
const SIM_MODES = ['osu!', 'taiko', 'catch', 'mania'] as const;
// Simulation parameters for preview
let simMode = $state<(typeof SIM_MODES)[number]>('osu!');
let simAction = $state<string>('Playing...');
let simBeatmapIndex = $state<number>(0);
let simRank = $state<number>(1337);
let simPp = $state<number>(6850);
let simAccuracy = $state<string>('98.84%');
let simUserStatus = $state<'online' | 'idle' | 'dnd'>('online');
// Live ticking simulation clock for authentic elapsed timestamp
let simElapsedSeconds = $state<number>(161); // starts at 02:41
let timerInterval: ReturnType<typeof setInterval> | null = null;
const TARGET_LABELS: Record<string, string> = {
idleDetails: 'Idle Details',
idleState: 'Idle State',
idleLargeImageText: 'Idle Large Tooltip',
playingDetails: 'Playing Details',
playingState: 'Playing State',
playingLargeImageText: 'Playing Large Tooltip',
playingSmallImageText: 'Playing Avatar Tooltip',
idleButton1Label: 'Idle Button 1 Label',
idleButton1Url: 'Idle Button 1 URL',
playingButton1Label: 'Playing Button 1 Label',
playingButton1Url: 'Playing Button 1 URL',
button2Label: 'Button 2 Label',
button2Url: 'Button 2 URL',
};
const FIELD_ELEMENT_IDS: Record<string, string> = {
idleDetails: 'idle-details',
idleState: 'idle-state',
idleLargeImageText: 'idle-large-text',
playingDetails: 'playing-details',
playingState: 'playing-state',
playingLargeImageText: 'playing-large-text',
playingSmallImageText: 'playing-small-text',
idleButton1Label: 'idle-btn1-label',
idleButton1Url: 'idle-btn1-url',
playingButton1Label: 'playing-btn1-label',
playingButton1Url: 'playing-btn1-url',
button2Label: 'btn2-label',
button2Url: 'btn2-url',
};
const SAMPLE_BEATMAPS = [
{
artist: 'Reol',
title: 'No title',
diff: 'Insane',
cover: 'https://assets.ppy.sh/beatmaps/320118/covers/[email protected]',
setId: 320118,
id: 712376,
},
{
artist: 'Camellia',
title: 'GHOST',
diff: 'Final Boss',
cover: 'https://assets.ppy.sh/beatmaps/1013444/covers/[email protected]',
setId: 1013444,
id: 2121386,
},
{
artist: 'Kano',
title: 'Stella-rium',
diff: 'Celestial',
cover: 'https://assets.ppy.sh/beatmaps/399404/covers/[email protected]',
setId: 399404,
id: 869286,
},
{
artist: 'xi',
title: 'FREEDOM DiVE',
diff: 'FOUR DIMENSIONS',
cover: 'https://assets.ppy.sh/beatmaps/39804/covers/[email protected]',
setId: 39804,
id: 129891,
},
];
const PLACEHOLDERS = [
{ token: '{username}', label: 'Player Name', desc: 'Current player username' },
{ token: '{rank}', label: 'Rank', desc: 'Global leaderboard rank' },
{ token: '{pp}', label: 'PP', desc: 'Performance points' },
{ token: '{accuracy}', label: 'Accuracy', desc: 'Profile or play accuracy' },
{ token: '{mode}', label: 'Game Mode', desc: 'Active osu! mode (osu!, taiko...)' },
{ token: '{action}', label: 'Action', desc: 'Playing, Editing, AFK...' },
{ token: '{beatmap}', label: 'Beatmap', desc: 'Full artist - title [diff]' },
{ token: '{artist}', label: 'Artist', desc: 'Beatmap song artist' },
{ token: '{title}', label: 'Title', desc: 'Beatmap song title' },
{ token: '{diff}', label: 'Difficulty', desc: 'Beatmap diff name' },
{ token: '{version}', label: 'Version', desc: 'Launcher version' },
{ token: '{beatmapUrl}', label: 'Map URL', desc: 'Beatmap URL on ez-pp.farm' },
{ token: '{profileUrl}', label: 'Profile URL', desc: 'User profile URL on ez-pp.farm' },
];
let unlistenSync: (() => void) | undefined;
onMount(async () => {
const loaded = await loadDiscordPresenceConfig();
workingConfig = JSON.parse(JSON.stringify(loaded));
initialConfigJson = JSON.stringify(loaded);
if (standalone) {
try {
unlistenSync = await listen<{
user?: Parameters<typeof currentUser.set>[0];
info?: Parameters<typeof currentUserInfo.set>[0];
launcherVersion?: string;
}>('sync-theme-editor-global-data', (event) => {
if (event.payload?.user) currentUser.set(event.payload.user);
if (event.payload?.info) currentUserInfo.set(event.payload.info);
if (event.payload?.launcherVersion) launcherVersion.set(event.payload.launcherVersion);
});
await emit('request-theme-editor-global-data');
} catch (err) {
console.warn('Could not sync global data to presence editor:', err);
}
}
timerInterval = setInterval(() => {
simElapsedSeconds++;
}, 1000);
});
onDestroy(() => {
unlistenSync?.();
if (blurTimeout) clearTimeout(blurTimeout);
if (timerInterval) clearInterval(timerInterval);
});
// When open changes, sync working config from store
$effect(() => {
if (open) {
loadDiscordPresenceConfig().then((loaded) => {
workingConfig = JSON.parse(JSON.stringify(loaded));
initialConfigJson = JSON.stringify(loaded);
});
}
});
const currentBeatmap = $derived(SAMPLE_BEATMAPS[simBeatmapIndex] ?? SAMPLE_BEATMAPS[0]);
// Evaluated variable context for preview
const previewContext = $derived<PresenceVariableContext>({
username: $currentUser?.name ?? 'Cookiezi',
id: $currentUser?.id ? String($currentUser.id) : '1001',
rank: simRank,
pp: simPp,
accuracy: simAccuracy,
mode: simMode,
action: simAction,
beatmap: `${currentBeatmap.artist} - ${currentBeatmap.title} [${currentBeatmap.diff}]`,
artist: currentBeatmap.artist,
title: currentBeatmap.title,
diff: currentBeatmap.diff,
version: $launcherVersion || '',
beatmapUrl: `https://ez-pp.farm/beatmapsets/${currentBeatmap.setId}/${currentBeatmap.id}`,
profileUrl: $currentUser?.id ? `https://ez-pp.farm/u/${$currentUser.id}` : 'https://ez-pp.farm',
});
// Interpolated fields for live preview
const livePreview = $derived.by(() => {
const isIdle = previewMode === 'idle';
if (isIdle) {
const details = interpolatePresenceString(workingConfig.idleDetails, previewContext);
const state = interpolatePresenceString(workingConfig.idleState, previewContext);
const largeText = interpolatePresenceString(workingConfig.idleLargeImageText, previewContext);
const btn1 = workingConfig.idleButton1.enabled
? {
label: interpolatePresenceString(workingConfig.idleButton1.label, previewContext),
url: interpolatePresenceString(workingConfig.idleButton1.url, previewContext),
}
: null;
const btn2 = workingConfig.button2.enabled
? {
label: interpolatePresenceString(workingConfig.button2.label, previewContext),
url: interpolatePresenceString(workingConfig.button2.url, previewContext),
}
: null;
return {
gameTitle: 'EZPPFarm Launcher',
details: details || 'In Launcher',
state: state || 'Chilling...',
largeImage: '/logo.png',
largeImageTooltip: largeText || 'EZPPFarm Launcher',
showSmallImage: workingConfig.idleShowUserAvatar,
smallImage: $currentUser?.id
? `https://a.ez-pp.farm/${$currentUser.id}`
: 'https://a.ez-pp.farm/1',
smallImageTooltip: $currentUser?.name ?? 'Player',
showTime: workingConfig.idleShowTime,
button1: btn1,
button2: btn2,
};
} else {
const details = interpolatePresenceString(workingConfig.playingDetails, previewContext);
const state = interpolatePresenceString(workingConfig.playingState, previewContext);
const largeText = interpolatePresenceString(
workingConfig.playingLargeImageText,
previewContext
);
const smallText = interpolatePresenceString(
workingConfig.playingSmallImageText,
previewContext
);
const btn1 = workingConfig.playingButton1.enabled
? {
label: interpolatePresenceString(workingConfig.playingButton1.label, previewContext),
url: interpolatePresenceString(workingConfig.playingButton1.url, previewContext),
}
: null;
const btn2 = workingConfig.button2.enabled
? {
label: interpolatePresenceString(workingConfig.button2.label, previewContext),
url: interpolatePresenceString(workingConfig.button2.url, previewContext),
}
: null;
return {
gameTitle: 'osu!',
details: details || '[osu!] Playing...',
state:
state || `${currentBeatmap.artist} - ${currentBeatmap.title} [${currentBeatmap.diff}]`,
largeImage: workingConfig.playingShowBeatmapCover ? currentBeatmap.cover : '/logo.png',
largeImageTooltip: largeText || `${currentBeatmap.artist} - ${currentBeatmap.title}`,
showSmallImage: workingConfig.playingShowUserAvatar,
smallImage: $currentUser?.id
? `https://a.ez-pp.farm/${$currentUser.id}`
: 'https://a.ez-pp.farm/1',
smallImageTooltip: smallText || `${previewContext.username} (#${previewContext.rank})`,
showTime: workingConfig.playingShowTime,
button1: btn1,
button2: btn2,
};
}
});
const handleInputFocus = (key: string) => {
if (blurTimeout) {
clearTimeout(blurTimeout);
blurTimeout = null;
}
focusedInputKey = key;
};
const handleInputBlur = () => {
// Delay clearing target to allow clicking placeholder buttons or switching inputs smoothly
blurTimeout = setTimeout(() => {
focusedInputKey = null;
}, 150);
};
// Jump to and focus a field when clicking on the live Discord card
const focusField = (key: string, tab?: 'idle' | 'playing' | 'buttons') => {
if (tab) {
activeTab = tab;
if (tab === 'idle' || tab === 'playing') {
previewMode = tab;
}
}
handleInputFocus(key);
requestAnimationFrame(() => {
const elId = FIELD_ELEMENT_IDS[key] ?? key;
const el = document.getElementById(elId);
if (el) {
el.focus();
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
};
const applyPreset = (presetName: string) => {
selectedPresetName = presetName;
const found = DISCORD_PRESENCE_PRESETS.find((p) => p.name === presetName);
if (!found) return;
workingConfig = {
...DEFAULT_DISCORD_PRESENCE_CONFIG,
...found.config,
idleButton1: {
...DEFAULT_DISCORD_PRESENCE_CONFIG.idleButton1,
...(found.config.idleButton1 ?? {}),
},
playingButton1: {
...DEFAULT_DISCORD_PRESENCE_CONFIG.playingButton1,
...(found.config.playingButton1 ?? {}),
},
button2: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.button2, ...(found.config.button2 ?? {}) },
};
sileo.info({ title: 'Preset Applied', description: presetName });
};
const resetToDefaults = () => {
selectedPresetName = 'EZPPFarm Default';
workingConfig = JSON.parse(JSON.stringify(DEFAULT_DISCORD_PRESENCE_CONFIG));
sileo.info({ title: 'Default Presets Restored' });
};
const insertPlaceholder = (token: string) => {
if (!focusedInputKey) return;
// Helper to insert into string at current input cursor position if applicable, else append
const insertInto = (current: string | undefined): string => {
const val = current ?? '';
if (
typeof document !== 'undefined' &&
document.activeElement instanceof HTMLInputElement &&
typeof document.activeElement.selectionStart === 'number'
) {
const start = document.activeElement.selectionStart;
const end = document.activeElement.selectionEnd ?? start;
const next = val.slice(0, start) + token + val.slice(end);
const nextCursor = start + token.length;
requestAnimationFrame(() => {
if (document.activeElement instanceof HTMLInputElement) {
document.activeElement.setSelectionRange(nextCursor, nextCursor);
}
});
return next;
}
return val + token;
};
switch (focusedInputKey) {
case 'idleDetails':
workingConfig.idleDetails = insertInto(workingConfig.idleDetails);
break;
case 'idleState':
workingConfig.idleState = insertInto(workingConfig.idleState);
break;
case 'idleLargeImageText':
workingConfig.idleLargeImageText = insertInto(workingConfig.idleLargeImageText);
break;
case 'playingDetails':
workingConfig.playingDetails = insertInto(workingConfig.playingDetails);
break;
case 'playingState':
workingConfig.playingState = insertInto(workingConfig.playingState);
break;
case 'playingLargeImageText':
workingConfig.playingLargeImageText = insertInto(workingConfig.playingLargeImageText);
break;
case 'playingSmallImageText':
workingConfig.playingSmallImageText = insertInto(workingConfig.playingSmallImageText);
break;
case 'idleButton1Label':
workingConfig.idleButton1.label = insertInto(workingConfig.idleButton1.label);
break;
case 'idleButton1Url':
workingConfig.idleButton1.url = insertInto(workingConfig.idleButton1.url);
break;
case 'playingButton1Label':
workingConfig.playingButton1.label = insertInto(workingConfig.playingButton1.label);
break;
case 'playingButton1Url':
workingConfig.playingButton1.url = insertInto(workingConfig.playingButton1.url);
break;
case 'button2Label':
workingConfig.button2.label = insertInto(workingConfig.button2.label);
break;
case 'button2Url':
workingConfig.button2.url = insertInto(workingConfig.button2.url);
break;
}
};
const handleSave = async () => {
isSaving = true;
try {
await saveDiscordPresenceConfig(workingConfig);
initialConfigJson = JSON.stringify(workingConfig);
if ($discordPresence && (await presence.isConnected())) {
await pushIdlePresenceFromConfig(
{
user: $currentUser,
version: $launcherVersion || undefined,
rank: simRank,
pp: simPp,
accuracy: simAccuracy,
},
workingConfig
);
}
sileo.success({ title: 'Discord Rich Presence Saved', description: 'Activity updated' });
await handleClose();
} catch (err) {
console.error(err);
sileo.error({ title: 'Failed to save configuration' });
} finally {
isSaving = false;
}
};
const handleClose = async () => {
open = false;
onclose?.();
if (standalone) {
try {
await invoke('close_presence_editor');
} catch {
try {
await getCurrentWindow().close();
} catch {}
}
}
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && (open || standalone)) {
handleClose();
}
};
const formatElapsed = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')} elapsed`;
};
const zoomIn = () => {
canvasScale = Math.min(1.4, Math.round((canvasScale + 0.1) * 10) / 10);
};
const zoomOut = () => {
canvasScale = Math.max(0.7, Math.round((canvasScale - 0.1) * 10) / 10);
};
const resetZoom = () => {
canvasScale = 1;
};
const testButtonClick = (label: string, url: string) => {
if (!url) {
sileo.warning({
title: 'No URL configured',
description: 'Provide a target link for this button',
});
return;
}
sileo.info({
title: `Button Action: ${label || 'Button'}`,
description: url,
});
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
}
};
</script>
<svelte:window onkeydown={handleKeydown} />
{#if standalone || open}
<div
class={standalone
? 'flex h-screen w-screen flex-col overflow-hidden bg-theme-950 text-theme-50 select-none'
: 'fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 select-none backdrop-blur-sm sm:p-6'}
transition:fade={{ duration: 120 }}
>
<!-- Modal Window (or full-window frame if standalone) -->
<div
class={standalone
? 'flex size-full flex-col overflow-hidden bg-theme-950 text-theme-50'
: 'flex h-[92vh] max-h-[880px] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-theme-800 bg-theme-950 text-theme-50 shadow-2xl'}
transition:scale={{ duration: 150, start: 0.98 }}
role="dialog"
aria-modal="true"
>
<!-- Studio Header (Same design as Theme Editor) -->
<header
class="flex h-14 shrink-0 items-center justify-between border-b border-theme-800 bg-black/40 px-4"
data-tauri-drag-region
>
<div class="flex min-w-0 items-center gap-3">
<div class="rounded-xl bg-primary/25 p-1 ring-1 ring-inset ring-white/15">
<!-- Discord icon -->
<svg class="size-7 fill-primary" viewBox="0 0 24 24">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994.021-.041.001-.09-.041-.106a13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.929 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.894.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="truncate text-sm font-semibold">Discord Rich Presence</h1>
<Badge
variant="outline"
class="border-primary/25 bg-primary/15 px-2 py-0.5 text-[9px] text-primary"
>
Discord RPC
</Badge>
{#if $discordPresence}
<Badge
variant="outline"
class="border-emerald-500/25 bg-emerald-500/15 px-2 py-0.5 text-[9px] text-emerald-400"
>
Connected
</Badge>
{:else}
<Badge
variant="outline"
class="border-zinc-500/25 bg-zinc-500/15 px-2 py-0.5 text-[9px] text-zinc-400"
>
Disabled
</Badge>
{/if}
</div>
<p class="truncate text-[10px] text-muted-foreground">
Customize activity details, state text, dynamic buttons, and interactive card
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="mr-2 hidden text-[10px] sm:block {isDirty
? 'text-amber-300'
: 'text-muted-foreground'}"
title={isDirty ? 'You have unsaved changes' : 'All changes saved'}
>
{isDirty ? 'Unsaved changes' : 'All changes saved'}
</span>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs text-muted-foreground hover:text-white"
onclick={resetToDefaults}
title="Reset to default settings"
>
<RotateCcw class="size-3.5" />
Reset
</Button>
<Button
size="sm"
class="h-8 gap-1.5 bg-primary text-primary-foreground shadow hover:bg-primary/90"
onclick={handleSave}
disabled={isSaving}
>
{#if isSaving}
<LoaderCircle class="size-3.5 animate-spin" />
Saving...
{:else}
<Check class="size-3.5" />
Save Changes
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-white"
onclick={handleClose}
aria-label="Close"
>
<X class="size-4" />
</Button>
</div>
</header>
<!-- Main Studio Body: Sidebar Controls + Interactive Canvas Stage -->
<div
class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[380px_minmax(0,1fr)]"
>
<!-- Left Sidebar: Studio Controls & Configuration (380px) -->
<aside
class="flex min-h-0 flex-col border-r border-theme-800 bg-theme-900/25 overflow-hidden"
>
<!-- Navigation Tabs (Matching Theme Editor design) -->
<div class="grid shrink-0 grid-cols-4 gap-1 border-b border-theme-800 p-2">
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1.5 text-[10px] {activeTab === 'idle'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={activeTab === 'idle'}
onclick={() => {
activeTab = 'idle';
previewMode = 'idle';
}}
>
<Home class="size-3.5" />
Idle
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1.5 text-[10px] {activeTab === 'playing'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={activeTab === 'playing'}
onclick={() => {
activeTab = 'playing';
previewMode = 'playing';
}}
>
<Gamepad2 class="size-3.5" />
In-Game
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1.5 text-[10px] {activeTab === 'buttons'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={activeTab === 'buttons'}
onclick={() => (activeTab = 'buttons')}
>
<Link class="size-3.5" />
Buttons
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1.5 text-[10px] {activeTab === 'presets'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={activeTab === 'presets'}
onclick={() => (activeTab = 'presets')}
>
<Sparkles class="size-3.5" />
Presets
</Button>
</div>
<!-- Tab Content (Scrollable with ThemeSection components) -->
<div class="flex-1 space-y-4 overflow-y-auto p-4 text-xs">
{#if activeTab === 'idle'}
<!-- Idle Tab -->
<ThemeSection
title="Activity Text"
icon={Type}
description="Text shown on the first and second lines of your Discord status when launcher is idle"
bordered={false}
>
<div class="space-y-3">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-[11px] font-medium text-theme-200" for="idle-details">
Details (First Line)
</Label>
<span class="text-[10px] text-muted-foreground">e.g. In Launcher</span>
</div>
<Input
id="idle-details"
bind:value={workingConfig.idleDetails}
onfocus={() => handleInputFocus('idleDetails')}
onblur={handleInputBlur}
placeholder="In Launcher"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-[11px] font-medium text-theme-200" for="idle-state">
State (Second Line)
</Label>
<span class="text-[10px] text-muted-foreground">e.g. Chilling...</span>
</div>
<Input
id="idle-state"
bind:value={workingConfig.idleState}
onfocus={() => handleInputFocus('idleState')}
onblur={handleInputBlur}
placeholder="Chilling..."
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
</div>
</ThemeSection>
<ThemeSection
title="Media & Badges"
icon={ImageIcon}
description="Launcher icon tooltips and user avatar overlay"
>
<div class="space-y-3">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-[11px] font-medium text-theme-200" for="idle-large-text">
Large Image Tooltip
</Label>
<span class="text-[10px] text-muted-foreground">Hovering launcher logo</span>
</div>
<Input
id="idle-large-text"
bind:value={workingConfig.idleLargeImageText}
onfocus={() => handleInputFocus('idleLargeImageText')}
onblur={handleInputBlur}
placeholder="EZPPFarm Launcher"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<div class="space-y-0.5">
<Label class="text-[11px] font-medium text-white" for="idle-avatar">
Show Avatar Badge
</Label>
<p class="text-[10px] text-muted-foreground">
Overlay your profile avatar on the corner of launcher icon
</p>
</div>
<Checkbox id="idle-avatar" bind:checked={workingConfig.idleShowUserAvatar} />
</div>
</div>
</ThemeSection>
<ThemeSection
title="Timestamp & Elapsed"
icon={Clock}
description="Live clock elapsed time display"
>
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<div class="space-y-0.5">
<Label class="text-[11px] font-medium text-white" for="idle-timer">
Show Elapsed Time
</Label>
<p class="text-[10px] text-muted-foreground">
Display time elapsed since launcher started
</p>
</div>
<Checkbox id="idle-timer" bind:checked={workingConfig.idleShowTime} />
</div>
</ThemeSection>
{:else if activeTab === 'playing'}
<!-- In-Game Tab -->
<ThemeSection
title="Gameplay Activity Text"
icon={Type}
description="Text shown on your Discord profile when playing in-game (supports beatmap tokens)"
bordered={false}
>
<div class="space-y-3">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-[11px] font-medium text-theme-200" for="playing-details">
Details (First Line)
</Label>
<span class="text-[10px] text-muted-foreground"
>[{'{'}mode}] {'{'}action}</span
>
</div>
<Input
id="playing-details"
bind:value={workingConfig.playingDetails}
onfocus={() => handleInputFocus('playingDetails')}
onblur={handleInputBlur}
placeholder="[{'{'}mode}] {'{'}action}"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-[11px] font-medium text-theme-200" for="playing-state">
State (Second Line)
</Label>
<span class="text-[10px] text-muted-foreground">{'{'}beatmap}</span>
</div>
<Input
id="playing-state"
bind:value={workingConfig.playingState}
onfocus={() => handleInputFocus('playingState')}
onblur={handleInputBlur}
placeholder="{'{'}beatmap}"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
</div>
</ThemeSection>
<ThemeSection
title="Beatmap Artwork & Badges"
icon={ImageIcon}
description="Beatmap cover banner, player badge, and hover tooltips"
>
<div class="space-y-3">
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<div class="space-y-0.5">
<Label class="text-[11px] font-medium text-white" for="playing-cover">
Show Beatmap Cover Art
</Label>
<p class="text-[10px] text-muted-foreground">
Display active beatmap banner instead of generic icon
</p>
</div>
<Checkbox
id="playing-cover"
bind:checked={workingConfig.playingShowBeatmapCover}
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label
class="text-[11px] font-medium text-theme-200"
for="playing-large-text"
>
Cover Art Hover Tooltip
</Label>
<span class="text-[10px] text-muted-foreground">{'{'}beatmap}</span>
</div>
<Input
id="playing-large-text"
bind:value={workingConfig.playingLargeImageText}
onfocus={() => handleInputFocus('playingLargeImageText')}
onblur={handleInputBlur}
placeholder="{'{'}beatmap}"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<div class="space-y-0.5">
<Label class="text-[11px] font-medium text-white" for="playing-avatar">
Show Player Avatar Badge
</Label>
<p class="text-[10px] text-muted-foreground">
Overlay small avatar badge on beatmap cover
</p>
</div>
<Checkbox
id="playing-avatar"
bind:checked={workingConfig.playingShowUserAvatar}
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label
class="text-[11px] font-medium text-theme-200"
for="playing-small-text"
>
Small Badge Hover Tooltip
</Label>
<span class="text-[10px] text-muted-foreground"
>{'{'}username} (#{'{'}rank})</span
>
</div>
<Input
id="playing-small-text"
bind:value={workingConfig.playingSmallImageText}
onfocus={() => handleInputFocus('playingSmallImageText')}
onblur={handleInputBlur}
placeholder="{'{'}username} (#{'{'}rank})"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
</div>
</ThemeSection>
<ThemeSection
title="In-Game Timestamp"
icon={Clock}
description="Live gameplay match timer"
>
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<div class="space-y-0.5">
<Label class="text-[11px] font-medium text-white" for="playing-timer">
Show Playing Elapsed Time
</Label>
<p class="text-[10px] text-muted-foreground">
Display elapsed playtime for current map
</p>
</div>
<Checkbox id="playing-timer" bind:checked={workingConfig.playingShowTime} />
</div>
</ThemeSection>
{:else if activeTab === 'buttons'}
<!-- Buttons Tab -->
<ThemeSection
title="Button 1 (Idle in Launcher)"
icon={Link}
description="Primary action button shown when idle in the launcher"
bordered={false}
>
<div class="space-y-3">
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<Label class="text-[11px] font-medium text-white">Enable Button 1 (Idle)</Label>
<Checkbox bind:checked={workingConfig.idleButton1.enabled} />
</div>
{#if workingConfig.idleButton1.enabled}
<div class="space-y-2 pt-1">
<div class="space-y-1">
<Label class="text-[10px] text-theme-300" for="idle-btn1-label">
Button Label
</Label>
<Input
id="idle-btn1-label"
bind:value={workingConfig.idleButton1.label}
onfocus={() => handleInputFocus('idleButton1Label')}
onblur={handleInputBlur}
placeholder="Download Launcher"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div class="space-y-1">
<Label class="text-[10px] text-theme-300" for="idle-btn1-url">
Target URL
</Label>
<Input
id="idle-btn1-url"
bind:value={workingConfig.idleButton1.url}
onfocus={() => handleInputFocus('idleButton1Url')}
onblur={handleInputBlur}
placeholder="https://..."
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
</div>
{/if}
</div>
</ThemeSection>
<ThemeSection
title="Button 1 (Playing In-Game)"
icon={Link}
description="Primary action button shown while playing osu! (supports {'{'}beatmapUrl})"
>
<div class="space-y-3">
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<Label class="text-[11px] font-medium text-white"
>Enable Button 1 (Playing)</Label
>
<Checkbox bind:checked={workingConfig.playingButton1.enabled} />
</div>
{#if workingConfig.playingButton1.enabled}
<div class="space-y-2 pt-1">
<div class="space-y-1">
<Label class="text-[10px] text-theme-300" for="playing-btn1-label">
Button Label
</Label>
<Input
id="playing-btn1-label"
bind:value={workingConfig.playingButton1.label}
onfocus={() => handleInputFocus('playingButton1Label')}
onblur={handleInputBlur}
placeholder="View Beatmap"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div class="space-y-1">
<Label class="text-[10px] text-theme-300" for="playing-btn1-url">
Target URL
</Label>
<Input
id="playing-btn1-url"
bind:value={workingConfig.playingButton1.url}
onfocus={() => handleInputFocus('playingButton1Url')}
onblur={handleInputBlur}
placeholder="{'{'}beatmapUrl}"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
</div>
{/if}
</div>
</ThemeSection>
<ThemeSection
title="Button 2 (Community & Profile)"
icon={Globe}
description="Secondary button displayed across both idle and gameplay states"
>
<div class="space-y-3">
<div
class="flex items-center justify-between rounded-lg border border-theme-800/60 bg-theme-900/30 p-2.5"
>
<Label class="text-[11px] font-medium text-white">Enable Button 2</Label>
<Checkbox bind:checked={workingConfig.button2.enabled} />
</div>
{#if workingConfig.button2.enabled}
<div class="space-y-2 pt-1">
<div class="space-y-1">
<Label class="text-[10px] text-theme-300" for="btn2-label">
Button Label
</Label>
<Input
id="btn2-label"
bind:value={workingConfig.button2.label}
onfocus={() => handleInputFocus('button2Label')}
onblur={handleInputBlur}
placeholder="Join EZPPFarm"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
<div class="space-y-1">
<Label class="text-[10px] text-theme-300" for="btn2-url">Target URL</Label>
<Input
id="btn2-url"
bind:value={workingConfig.button2.url}
onfocus={() => handleInputFocus('button2Url')}
onblur={handleInputBlur}
placeholder="https://ez-pp.farm/discord"
class="h-8 bg-black/40 text-xs border-theme-800 focus:border-primary"
/>
</div>
</div>
{/if}
</div>
</ThemeSection>
{:else if activeTab === 'presets'}
<!-- Presets Tab -->
<ThemeSection
title="Built-in Presets"
icon={Sparkles}
description="Apply carefully tuned activity templates"
bordered={false}
>
<div class="space-y-2">
{#each DISCORD_PRESENCE_PRESETS as preset (preset.name)}
<div
class="flex flex-col gap-1.5 rounded-xl border border-theme-800/80 bg-theme-900/40 p-3 transition-colors hover:border-theme-700 {selectedPresetName ===
preset.name
? 'ring-1 ring-primary/60 bg-primary/5'
: ''}"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-white">{preset.name}</span>
{#if selectedPresetName === preset.name}
<Badge
variant="outline"
class="border-primary/30 bg-primary/10 text-[9px] text-primary"
>
Active
</Badge>
{/if}
</div>
<Button
variant={selectedPresetName === preset.name ? 'secondary' : 'ghost'}
size="sm"
class="h-7 text-[10px]"
onclick={() => applyPreset(preset.name)}
>
Apply
</Button>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
{preset.description}
</p>
</div>
{/each}
</div>
</ThemeSection>
<ThemeSection
title="Dynamic Placeholders"
icon={Code2}
description="Click any variable to copy or evaluate in the active field"
>
<div class="space-y-1.5">
{#each PLACEHOLDERS as p (p.token)}
<div
class="flex items-center justify-between rounded-lg border border-theme-800/40 bg-black/30 px-2.5 py-1.5 text-[11px]"
>
<div class="flex items-center gap-2 min-w-0">
<code class="font-mono text-primary font-semibold text-[10px]"
>{p.token}</code
>
<span class="truncate text-muted-foreground text-[10px]">{p.label}</span>
</div>
<span class="font-mono text-[9px] text-theme-300 truncate max-w-[120px]">
{interpolatePresenceString(p.token, previewContext)}
</span>
</div>
{/each}
</div>
</ThemeSection>
{/if}
</div>
<!-- Clickable Variables / Placeholder Ribbon (only shown when an input is actively clicked/focused) -->
{#if focusedInputKey}
<div
transition:slide={{ duration: 160 }}
role="toolbar"
aria-label="Placeholder tokens"
tabindex="-1"
class="border-t border-theme-800/80 bg-black/60 p-3 shrink-0"
onmousedown={(e) => {
// Prevent blurring the active input when clicking anywhere within the ribbon
e.preventDefault();
}}
>
<div class="mb-1.5 flex items-center justify-between">
<span class="text-[10px] font-medium text-muted-foreground">
Click token to insert at cursor:
</span>
<span class="text-[10px] text-theme-400">
Target: <code
class="rounded bg-theme-800 px-1.5 py-0.5 text-primary font-mono font-medium text-[9px]"
>{TARGET_LABELS[focusedInputKey] ?? focusedInputKey}</code
>
</span>
</div>
<div class="flex flex-wrap gap-1">
{#each PLACEHOLDERS as p (p.token)}
<button
type="button"
class="group flex items-center gap-1 rounded border border-theme-800 bg-theme-900/90 px-1.5 py-0.5 text-[10px] text-theme-200 transition-all hover:border-primary/60 hover:bg-primary/20 hover:text-white active:scale-95"
onmousedown={(e) => e.preventDefault()}
onclick={() => insertPlaceholder(p.token)}
title={`${p.label}: ${p.desc}`}
>
<span class="font-mono text-primary group-hover:text-primary-foreground">+</span
>
<span class="font-mono">{p.token}</span>
</button>
{/each}
</div>
</div>
{/if}
</aside>
<!-- Right Main Stage: Live Interactive Canvas (Matching Theme Editor canvas design) -->
<main class="flex min-h-0 min-w-0 flex-col bg-theme-900/50 overflow-hidden">
<!-- Canvas Toolbar Header -->
<div
class="flex h-10 shrink-0 items-center justify-between border-b border-theme-800 bg-theme-900/30 px-3 text-[10px] text-muted-foreground"
>
<div class="flex items-center gap-2">
<span class="size-1.5 shrink-0 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="font-medium text-theme-200">Discord Embed</span>
<span class="text-theme-600">·</span>
<span class="text-muted-foreground">Live Interactive Canvas</span>
</div>
<div class="flex items-center gap-1.5">
<!-- Preview Mode Switcher (Idle vs In-Game) -->
<div class="flex items-center rounded-lg border border-theme-800 bg-black/40 p-0.5">
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {previewMode ===
'idle'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-white'}"
onclick={() => (previewMode = 'idle')}
>
<Home class="size-3" />
Idle
</button>
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {previewMode ===
'playing'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-white'}"
onclick={() => (previewMode = 'playing')}
>
<Gamepad2 class="size-3" />
In-Game
</button>
</div>
<span class="h-4 w-px bg-theme-800"></span>
<!-- Interaction Mode: Click-to-Edit vs Test Actions -->
<div class="flex items-center rounded-lg border border-theme-800 bg-black/40 p-0.5">
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {cardInteractionMode ===
'edit'
? 'bg-primary/20 text-primary'
: 'text-muted-foreground hover:text-white'}"
title="Click elements on the card to focus their field in the editor"
onclick={() => (cardInteractionMode = 'edit')}
>
<MousePointerClick class="size-3" />
Edit on Click
</button>
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {cardInteractionMode ===
'test'
? 'bg-primary/20 text-primary'
: 'text-muted-foreground hover:text-white'}"
title="Click buttons on the card to test links and actions"
onclick={() => (cardInteractionMode = 'test')}
>
<Play class="size-3" />
Test Embed
</button>
</div>
<span class="h-4 w-px bg-theme-800"></span>
<!-- Zoom Controls -->
<div class="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Zoom out"
onclick={zoomOut}
>
<Minus class="size-3" />
</Button>
<button
type="button"
class="h-6 min-w-10 px-1 font-mono text-[10px] text-theme-100 hover:text-primary transition-colors"
title="Reset zoom to 100%"
onclick={resetZoom}
>
{Math.round(canvasScale * 100)}%
</button>
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Zoom in"
onclick={zoomIn}
>
<Plus class="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Reset zoom"
onclick={resetZoom}
>
<Maximize2 class="size-3" />
</Button>
</div>
</div>
</div>
<!-- Simulator Bar (when in-game is previewed) -->
{#if previewMode === 'playing'}
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-theme-800 bg-theme-900/20 px-3 py-1.5 text-[10px]"
>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">Mode:</span>
<div class="flex gap-0.5">
{#each SIM_MODES as mode (mode)}
<button
type="button"
class="rounded px-2 py-0.5 text-[10px] font-semibold transition-colors {simMode ===
mode
? 'bg-primary text-primary-foreground'
: 'bg-black/40 text-theme-300 hover:text-white'}"
onclick={() => (simMode = mode)}
>
{mode}
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">Action:</span>
<div class="flex gap-0.5">
{#each ['Playing...', 'Editing...', 'AFK...'] as act (act)}
<button
type="button"
class="rounded px-2 py-0.5 text-[10px] transition-colors {simAction === act
? 'bg-primary text-primary-foreground font-semibold'
: 'bg-black/40 text-theme-300 hover:text-white'}"
onclick={() => (simAction = act)}
>
{act.replace('...', '')}
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">Beatmap:</span>
<select
bind:value={simBeatmapIndex}
class="rounded border border-theme-800 bg-black/60 px-2 py-0.5 text-[10px] text-theme-200"
>
{#each SAMPLE_BEATMAPS as b, idx (`${b.artist}-${b.title}-${idx}`)}
<option value={idx}>{b.artist} - {b.title}</option>
{/each}
</select>
</div>
</div>
{/if}
<!-- Canvas Stage with Dotted Grid Background -->
<div
class="relative flex min-h-0 flex-1 items-center justify-center overflow-auto p-6"
style="background-image:radial-gradient(circle at 1px 1px, rgba(255,255,255,0.10) 1px, transparent 0);background-size:24px 24px;"
role="presentation"
>
<!-- Scalable Card Container -->
<div
class="transition-transform duration-150 ease-out origin-center"
style="transform: scale({canvasScale});"
>
<Tooltip.Provider delayDuration={150}>
<!-- Authentic Discord Profile & Rich Presence Card -->
<div
class="w-[340px] overflow-hidden rounded-2xl bg-[#1e1f22] text-[#dbdee1] shadow-2xl ring-1 ring-[#2b2d31]"
>
<!-- Discord Banner -->
<div
class="relative h-14 bg-gradient-to-r from-[#5865F2]/40 via-[#3ba55d]/30 to-[#f47b67]/40"
>
<!-- Avatar circle overlapping banner -->
<div class="absolute -bottom-6 left-4">
<div class="relative size-14 rounded-full bg-[#111214] ring-4 ring-[#1e1f22]">
<img
src={$currentUser?.id
? `https://a.ez-pp.farm/${$currentUser.id}`
: 'https://a.ez-pp.farm/1'}
alt="Avatar"
class="size-full rounded-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).src = '/logo.png';
}}
/>
<!-- Interactive Status Badge (Click to cycle online/idle/dnd) -->
<button
type="button"
class="absolute -bottom-0.5 -right-0.5 size-4 rounded-full border-2 border-[#1e1f22] cursor-pointer transition-transform hover:scale-110 {simUserStatus ===
'online'
? 'bg-[#23a55a]'
: simUserStatus === 'idle'
? 'bg-[#f0b232]'
: 'bg-[#f23f43]'}"
title={`Status: ${simUserStatus} (Click to toggle)`}
onclick={() => {
simUserStatus =
simUserStatus === 'online'
? 'idle'
: simUserStatus === 'idle'
? 'dnd'
: 'online';
}}
></button>
</div>
</div>
</div>
<!-- User Handle Area -->
<div class="px-4 pt-7 pb-3">
<div class="text-sm font-bold text-white">
{$currentUser?.name ?? 'Cookiezi'}
</div>
<div class="text-[11px] font-medium text-[#949ba4]">
@{$currentUser?.name ? $currentUser.name.toLowerCase() : 'cookiezi'}
</div>
</div>
<div class="mx-4 border-t border-[#2b2d31]"></div>
<!-- Rich Presence Activity Section -->
<div class="p-4 space-y-3">
<!-- Header: PLAYING A GAME -->
<div
class="flex items-center justify-between text-[11px] font-bold tracking-wider text-[#b5bac1] uppercase"
>
<span>PLAYING A GAME</span>
<span class="text-[9px] font-normal text-[#80848e] capitalize">
{previewMode === 'idle' ? 'Launcher' : 'Game'}
</span>
</div>
<!-- Activity Details Row -->
<div class="flex items-start gap-3">
<!-- Large Activity Image with shadcn Tooltip & Small Badge Container -->
<div class="relative size-20 shrink-0">
<Tooltip.Root>
<Tooltip.Trigger
class="relative size-full overflow-hidden rounded-xl bg-[#2b2d31] ring-1 ring-white/5 transition-all {focusedInputKey ===
(previewMode === 'idle'
? 'idleLargeImageText'
: 'playingLargeImageText')
? 'ring-2 ring-primary scale-105'
: cardInteractionMode === 'edit'
? 'hover:ring-2 hover:ring-primary/60 cursor-pointer'
: ''}"
title={cardInteractionMode === 'edit'
? 'Click to edit image tooltip'
: ''}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField(
previewMode === 'idle'
? 'idleLargeImageText'
: 'playingLargeImageText',
previewMode
);
}
}}
>
<img
src={livePreview.largeImage}
alt="Activity Icon"
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).src = '/logo.png';
}}
/>
</Tooltip.Trigger>
{#if livePreview.largeImageTooltip}
<Tooltip.Content
side="top"
sideOffset={8}
class="bg-[#111214] text-white border-zinc-800 text-[11px] font-semibold px-2.5 py-1 shadow-2xl z-50 pointer-events-none"
>
{livePreview.largeImageTooltip}
<Tooltip.Arrow
class="text-[#111214] fill-[#111214]"
style="color: #111214;"
/>
</Tooltip.Content>
{/if}
</Tooltip.Root>
<!-- Small Avatar Badge Overlay (with shadcn Tooltip) -->
{#if livePreview.showSmallImage}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute -bottom-1 -right-1 size-7 overflow-hidden rounded-full border-2 border-[#1e1f22] bg-[#111214] transition-all z-20 {focusedInputKey ===
(previewMode === 'idle'
? 'idleLargeImageText'
: 'playingSmallImageText')
? 'ring-2 ring-primary scale-110'
: cardInteractionMode === 'edit'
? 'hover:ring-2 hover:ring-primary/60 cursor-pointer'
: ''}"
title={cardInteractionMode === 'edit'
? 'Click to edit avatar badge'
: ''}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField(
previewMode === 'idle'
? 'idleLargeImageText'
: 'playingSmallImageText',
previewMode
);
}
}}
>
<img
src={livePreview.smallImage}
alt="Badge"
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).src = '/logo.png';
}}
/>
</Tooltip.Trigger>
{#if livePreview.smallImageTooltip}
<Tooltip.Content
side="top"
sideOffset={6}
class="bg-[#111214] text-white border-zinc-800 text-[10px] font-semibold px-2 py-0.5 shadow-2xl z-50 pointer-events-none"
>
{livePreview.smallImageTooltip}
<Tooltip.Arrow
class="text-[#111214] fill-[#111214]"
style="color: #111214;"
/>
</Tooltip.Content>
{/if}
</Tooltip.Root>
{/if}
</div>
<!-- Text Block (Interactive Fields) -->
<div class="min-w-0 flex-1 space-y-0.5">
<!-- Game Title -->
<div class="truncate text-xs font-bold text-white">
{livePreview.gameTitle}
</div>
<!-- Details Line -->
<button
type="button"
class="w-full text-left truncate text-xs transition-all rounded px-1 -mx-1 {focusedInputKey ===
(previewMode === 'idle' ? 'idleDetails' : 'playingDetails')
? 'ring-2 ring-primary bg-primary/15 text-white font-medium'
: cardInteractionMode === 'edit'
? 'text-[#dbdee1] hover:ring-1 hover:ring-primary/60 hover:bg-white/5 cursor-pointer'
: 'text-[#dbdee1]'}"
title={cardInteractionMode === 'edit' ? 'Click to edit details' : ''}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField(
previewMode === 'idle' ? 'idleDetails' : 'playingDetails',
previewMode
);
}
}}
>
{livePreview.details}
</button>
<!-- State Line -->
<button
type="button"
class="w-full text-left truncate text-xs transition-all rounded px-1 -mx-1 {focusedInputKey ===
(previewMode === 'idle' ? 'idleState' : 'playingState')
? 'ring-2 ring-primary bg-primary/15 text-white font-medium'
: cardInteractionMode === 'edit'
? 'text-[#949ba4] hover:ring-1 hover:ring-primary/60 hover:bg-white/5 cursor-pointer'
: 'text-[#949ba4]'}"
title={cardInteractionMode === 'edit' ? 'Click to edit state' : ''}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField(
previewMode === 'idle' ? 'idleState' : 'playingState',
previewMode
);
}
}}
>
{livePreview.state}
</button>
<!-- Elapsed Time (Real-Time Counter) -->
{#if livePreview.showTime}
<button
type="button"
class="flex items-center gap-1 text-[11px] text-[#949ba4] transition-all rounded px-1 -mx-1 {cardInteractionMode ===
'edit'
? 'hover:ring-1 hover:ring-primary/60 hover:bg-white/5 cursor-pointer'
: ''}"
title={cardInteractionMode === 'edit'
? 'Click to configure timestamp'
: ''}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField(
previewMode === 'idle' ? 'idleDetails' : 'playingDetails',
previewMode
);
}
}}
>
<Clock class="size-3" />
<span>{formatElapsed(simElapsedSeconds)}</span>
</button>
{/if}
</div>
</div>
<!-- Discord Action Buttons (Interactive) -->
<div class="space-y-1.5 pt-1">
{#if livePreview.button1}
<div
class="flex items-center gap-1 w-full rounded-md bg-[#2b2d31] p-1 text-xs font-semibold text-white transition-all hover:bg-[#35373c] border border-white/5 {focusedInputKey ===
(previewMode === 'idle' ? 'idleButton1Label' : 'playingButton1Label') ||
focusedInputKey ===
(previewMode === 'idle' ? 'idleButton1Url' : 'playingButton1Url')
? 'ring-2 ring-primary bg-primary/20'
: ''}"
>
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 py-1 text-xs truncate cursor-pointer"
title={cardInteractionMode === 'edit'
? 'Click to edit Button 1'
: `Opens: ${livePreview.button1.url}`}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField(
previewMode === 'idle'
? 'idleButton1Label'
: 'playingButton1Label',
'buttons'
);
} else {
testButtonClick(
livePreview.button1?.label ?? '',
livePreview.button1?.url ?? ''
);
}
}}
>
<ExternalLink class="size-3 text-[#b5bac1]" />
<span class="truncate">{livePreview.button1.label || 'Button 1'}</span>
</button>
<button
type="button"
class="size-6 flex items-center justify-center rounded hover:bg-white/10 text-[#b5bac1] hover:text-white cursor-pointer"
title={`Test Link: ${livePreview.button1.url}`}
onclick={() =>
testButtonClick(
livePreview.button1?.label ?? '',
livePreview.button1?.url ?? ''
)}
>
<Play class="size-2.5" />
</button>
</div>
{/if}
{#if livePreview.button2}
<div
class="flex items-center gap-1 w-full rounded-md bg-[#2b2d31] p-1 text-xs font-semibold text-white transition-all hover:bg-[#35373c] border border-white/5 {focusedInputKey ===
'button2Label' || focusedInputKey === 'button2Url'
? 'ring-2 ring-primary bg-primary/20'
: ''}"
>
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 py-1 text-xs truncate cursor-pointer"
title={cardInteractionMode === 'edit'
? 'Click to edit Button 2'
: `Opens: ${livePreview.button2.url}`}
onclick={() => {
if (cardInteractionMode === 'edit') {
focusField('button2Label', 'buttons');
} else {
testButtonClick(
livePreview.button2?.label ?? '',
livePreview.button2?.url ?? ''
);
}
}}
>
<ExternalLink class="size-3 text-[#b5bac1]" />
<span class="truncate">{livePreview.button2.label || 'Button 2'}</span>
</button>
<button
type="button"
class="size-6 flex items-center justify-center rounded hover:bg-white/10 text-[#b5bac1] hover:text-white cursor-pointer"
title={`Test Link: ${livePreview.button2.url}`}
onclick={() =>
testButtonClick(
livePreview.button2?.label ?? '',
livePreview.button2?.url ?? ''
)}
>
<Play class="size-2.5" />
</button>
</div>
{/if}
{#if !livePreview.button1 && !livePreview.button2}
<button
type="button"
class="w-full rounded-md border border-dashed border-[#2b2d31] py-2 text-center text-[10px] text-[#80848e] hover:border-primary/50 hover:text-primary transition-colors cursor-pointer"
onclick={() => {
activeTab = 'buttons';
focusField(
previewMode === 'idle' ? 'idleButton1Label' : 'playingButton1Label',
'buttons'
);
}}
>
+ Add interactive buttons in Buttons tab
</button>
{/if}
</div>
</div>
</div></Tooltip.Provider
>
</div>
</div>
<!-- Canvas Footer Bar -->
<div
class="flex h-8 shrink-0 items-center justify-between border-t border-theme-800 bg-theme-900/30 px-4 text-[9px] text-muted-foreground"
>
<div class="flex items-center gap-2">
{#if cardInteractionMode === 'edit'}
<span class="flex items-center gap-1 text-primary">
<MousePointerClick class="size-3" />
Click any field on the card to edit directly in the form
</span>
{:else}
<span class="flex items-center gap-1 text-emerald-400">
<Play class="size-3" />
Interactive test mode — click buttons to test links and actions
</span>
{/if}
<span class="text-theme-700">|</span>
<span>Esc to close</span>
</div>
<div class="flex items-center gap-3 font-mono">
<span>Preset: <strong class="text-theme-200">{selectedPresetName}</strong></span>
<span class="text-theme-700">|</span>
<span>Discord IPC v2</span>
</div>
</div>
</main>
</div>
</div>
</div>
{/if}