Website Status

This commit is contained in:
2025-11-29 16:40:05 +00:00
parent e02fdf59f4
commit 1b356dd6aa
24 changed files with 1294 additions and 52 deletions

View File

@@ -12,6 +12,32 @@
let mobileMenuOpen = $state(false);
let currentTime = $state(new Date());
// Discord presence from layout data
let discordStatus = $state('offline');
let discordDisplayName: string | null = $state(null);
let discordActivities: any[] = $state([]);
let discordAvatarUrl: string | null = $state(null);
let discordGuildTag: string | null = $state(null);
let guildTagBadgeImage: string | null = $state(null);
let discordPopoverOpen = $state(false);
$effect(() => {
discordStatus = $page.data?.status ?? 'offline';
discordDisplayName = $page.data?.displayName ?? null;
discordAvatarUrl = $page.data?.avatarUrl ?? null;
discordGuildTag = $page.data?.guildTag ?? null;
guildTagBadgeImage = $page.data?.guildTagBadgeImage ?? null;
// Filter out Custom Status (type 4)
discordActivities = ($page.data?.activities ?? []).filter((a: any) => a.type !== 4);
});
// Derived: first displayable activity (if any)
let currentActivity = $derived(discordActivities.length > 0 ? discordActivities[0] : null);
// Battery state (computed from EST time)
let batteryLevel = $state(50);
let batteryCharging = $state(false);
// Derived values (Svelte 5 runes)
let currentPath = $derived($page.url.pathname);
@@ -22,6 +48,51 @@
// Update time every second
timeInterval = setInterval(() => {
currentTime = new Date();
// Compute battery using America/New_York timezone (DST-aware)
function zoneEpochFromMs(ms: number, timeZone: string) {
const date = new Date(ms);
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const parts = fmt.formatToParts(date).reduce((acc: any, p: any) => {
acc[p.type] = p.value;
return acc;
}, {});
const iso = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}Z`;
return Date.parse(iso);
}
function computeBatteryFromMs(ms: number) {
const dischargeMs = 5 * 3600 * 1000; // 5 hours
const chargeMs = 0.5 * 3600 * 1000; // 30 minutes
const cycleMs = dischargeMs + chargeMs; // 5.5 hours
// position in cycle using America/New_York wall-clock epoch (DST-aware)
const estMs = zoneEpochFromMs(ms, 'America/New_York');
let pos = estMs % cycleMs;
if (pos < 0) pos += cycleMs;
if (pos < dischargeMs) {
// discharging: 100 -> 0
const pct = 100 - (pos / dischargeMs) * 100;
return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: false };
} else {
// charging: 0 -> 100
const chargePos = pos - dischargeMs;
const pct = (chargePos / chargeMs) * 100;
return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: true };
}
}
const { level, charging } = computeBatteryFromMs(Date.now());
batteryLevel = level;
batteryCharging = charging;
}, 1000);
});
@@ -72,6 +143,22 @@
if (event.key === 'Escape') {
themeDropdownOpen = false;
mobileMenuOpen = false;
discordPopoverOpen = false;
}
}
function toggleDiscordPopover() {
discordPopoverOpen = !discordPopoverOpen;
}
function getActivityTypeLabel(type: number): string {
switch (type) {
case 0: return 'Playing';
case 1: return 'Streaming';
case 2: return 'Listening to';
case 3: return 'Watching';
case 5: return 'Competing in';
default: return '';
}
}
@@ -184,6 +271,123 @@
<div class="bar-right">
<!-- (No system modules — minimal Waybar look) -->
<!-- Discord status with popover -->
<div class="module discord-status">
<button
class="discord-status-btn"
onclick={toggleDiscordPopover}
title={discordDisplayName ? `${discordDisplayName} ${discordStatus}` : `Discord: ${discordStatus}`}
aria-expanded={discordPopoverOpen}
>
<span
class="status-dot"
class:online={discordStatus === 'online'}
class:idle={discordStatus === 'idle'}
class:dnd={discordStatus === 'dnd'}
class:offline={discordStatus === 'offline'}
></span>
{#if discordDisplayName}
<span class="status-text">{discordDisplayName}</span>
{/if}
</button>
{#if discordPopoverOpen}
<div
class="discord-popover"
transition:fly={{ y: -10, duration: 150 }}
>
<!-- Header with avatar and name -->
<div class="popover-header">
<div class="popover-avatar">
{#if discordAvatarUrl}
<img src={discordAvatarUrl} alt="" class="avatar-img" />
{:else}
<Icon icon="mdi:account-circle" width="48" />
{/if}
<span
class="popover-status-dot"
class:online={discordStatus === 'online'}
class:idle={discordStatus === 'idle'}
class:dnd={discordStatus === 'dnd'}
class:offline={discordStatus === 'offline'}
></span>
</div>
<div class="popover-user-info">
<div class="popover-name-row">
<span class="popover-username">{discordDisplayName ?? 'Unknown'}</span>
{#if discordGuildTag}
<span class="popover-clan-tag">
<img src={guildTagBadgeImage} alt={discordGuildTag ?? 'Guild'} class="popover-clan-badge" />
{discordGuildTag}
</span>
{/if}
</div>
<span class="popover-status-label">{discordStatus}</span>
</div>
<button class="popover-close" onclick={() => discordPopoverOpen = false} aria-label="Close">
<Icon icon="mdi:close" width="16" />
</button>
</div>
<!-- Divider -->
<div class="popover-divider"></div>
<!-- Activities -->
{#if discordActivities.length > 0}
<div class="popover-activities">
{#each discordActivities as activity}
<div class="activity-card">
<div class="activity-type-label">{getActivityTypeLabel(activity.type)}</div>
<div class="activity-content">
{#if activity.assets?.large_image}
<div class="activity-images">
<img
src={activity.assets.large_image}
alt=""
class="activity-large-image"
/>
{#if activity.assets?.small_image}
<img
src={activity.assets.small_image}
alt=""
class="activity-small-image"
/>
{/if}
</div>
{/if}
<div class="activity-info">
<span class="activity-name">{activity.name}</span>
{#if activity.details}
<span class="activity-details">{activity.details}</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="popover-no-activity">
<Icon icon="mdi:sleep" width="24" />
<span>No current activity</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Battery module (EST-based simulated battery cycle) -->
<div class="module battery" title="Battery">
<button
class="battery-btn"
aria-label={`Battery ${batteryLevel}% ${batteryCharging ? 'charging' : 'discharging'}`}
class:low={batteryLevel < 20 && !batteryCharging}
class:charging={batteryCharging}
>
<Icon icon={getBatteryIcon(batteryLevel, batteryCharging)} width="16" />
<span class="battery-level" class:battery-alert={batteryLevel < 20 && !batteryCharging} class:charging={batteryCharging}>{batteryLevel}%</span>
</button>
</div>
<!-- Theme selector -->
<div class="module theme-selector">
<button
@@ -227,12 +431,12 @@
</div>
</div>
<!-- Backdrop for theme dropdown only -->
{#if themeDropdownOpen}
<!-- Backdrop for dropdowns -->
{#if themeDropdownOpen || discordPopoverOpen}
<button
class="backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { themeDropdownOpen = false; }}
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
aria-label="Close"
></button>
{/if}
@@ -320,10 +524,9 @@
class="mobile-theme-btn"
class:active={$colorTheme === option.value}
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
title={`Press T+${i+1} to switch to ${option.label}`}
>
<Icon icon={getThemeIcon(option.value)} width="18" />
<span class="theme-label"><span class="theme-number">{i+1}.</span> {option.label}</span>
<span class="theme-label">{option.label} <span class="theme-number">[{i+1}]</span></span>
</button>
{/each}
</div>