Files
Website/src/lib/components/NavbarWaybar.svelte
2025-11-29 22:14:25 +00:00

533 lines
16 KiB
Svelte

<script lang="ts">
import { mode, getThemeIcon, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
import { page } from '$app/stores';
import { fly, fade, slide } from 'svelte/transition';
import { user, navigation } from '$lib/config';
import Icon from '@iconify/svelte';
import { onMount, onDestroy } from 'svelte';
import '$lib/assets/css/navbar-waybar.css';
// State
let themeDropdownOpen = $state(false);
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);
// Time update interval
let timeInterval: ReturnType<typeof setInterval>;
onMount(() => {
// 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);
});
onDestroy(() => {
if (timeInterval) clearInterval(timeInterval);
});
// Format time
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
// Get current workspace/page index
function getWorkspaceIndex(path: string): number {
const idx = navigation.findIndex(n => n.path === path);
return idx >= 0 ? idx + 1 : 1;
}
// Battery icon based on level
function getBatteryIcon(level: number, charging: boolean): string {
if (charging) return 'mdi:battery-charging';
if (level > 90) return 'mdi:battery';
if (level > 70) return 'mdi:battery-80';
if (level > 50) return 'mdi:battery-60';
if (level > 30) return 'mdi:battery-40';
if (level > 10) return 'mdi:battery-20';
return 'mdi:battery-alert';
}
function handleThemeSelect(theme: ColorTheme) {
setColorTheme(theme);
themeDropdownOpen = false;
}
function handleKeydown(event: KeyboardEvent) {
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 '';
}
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
</script>
<svelte:window on:keydown={handleKeydown} />
<nav
class="waybar"
style="
--bar-bg: {$themeColors.background};
--bar-bg-module: {$themeColors.backgroundLight};
--bar-border: {$themeColors.border};
--bar-text: {$themeColors.text};
--bar-primary: {$themeColors.primary};
--bar-accent: {$themeColors.accent};
--bar-muted: {$themeColors.textMuted};
--bar-success: {$themeColors.colorMap.success};
--bar-warning: {$themeColors.colorMap.warning};
--bar-error: {$themeColors.colorMap.error};
"
>
<!-- Left modules -->
<div class="bar-left">
<!-- Arch logo / Launcher -->
<!-- <a href="/" class="module launcher" title="Home">
<img src="/favicon.png" alt="Blob Icon" width="16" />
</a> -->
<!-- Mobile menu toggle -->
<button
class="mobile-menu-toggle"
onclick={() => mobileMenuOpen = !mobileMenuOpen}
aria-expanded={mobileMenuOpen}
aria-label="Toggle navigation menu"
>
<Icon icon={mobileMenuOpen ? 'mdi:close' : 'mdi:menu'} width="20" />
</button>
<!-- Workspaces (desktop) -->
<div class="module workspaces desktop-only">
{#each navigation as nav}
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
{#if nav.external}
<a
href={nav.path}
class="workspace external"
target="_blank"
rel="noopener noreferrer"
title={nav.name}
>
<Icon icon="mdi:open-in-new" width="12" />
<span class="ws-name">{nav.name}</span>
</a>
{:else}
<a
href={nav.path}
class="workspace"
class:active={isActive}
title={nav.name}
>
<span class="ws-name">{nav.name}</span>
</a>
{/if}
{/each}
</div>
<!-- Current window title (desktop) -->
<div class="module window-title desktop-only">
<span class="title-icon">
{#if currentPath === '/'}
<Icon icon="mdi:home" width="14" />
{:else if currentPath === '/portfolio'}
<Icon icon="mdi:folder-multiple" width="14" />
{:else if currentPath === '/models'}
<Icon icon="mdi:cube-outline" width="14" />
{:else if currentPath === '/projects'}
<Icon icon="mdi:trophy" width="14" />
{:else}
<Icon icon="mdi:file" width="14" />
{/if}
</span>
<span class="title-text">
{navigation.find(n => n.path === currentPath)?.name || 'page'}
</span>
</div>
</div>
<!-- Center modules -->
<div class="bar-center">
<div class="module clock">
<Icon icon="mdi:clock-outline" width="14" />
<span class="time">{formatTime(currentTime)}</span>
<span class="date">{formatDate(currentTime)}</span>
</div>
</div>
<!-- Right modules -->
<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
class="theme-trigger"
onclick={() => themeDropdownOpen = !themeDropdownOpen}
aria-expanded={themeDropdownOpen}
title="Theme: {$colorTheme}"
>
<Icon icon={getThemeIcon($colorTheme)} width="16" />
</button>
{#if themeDropdownOpen}
<div
class="theme-dropdown"
transition:fly={{ y: -10, duration: 150 }}
>
<div class="dropdown-header">Theme</div>
{#each themeOptions as option, i}
<button
class="theme-option"
class:active={$colorTheme === option.value}
onclick={() => handleThemeSelect(option.value)}
>
<Icon icon={getThemeIcon(option.value)} width="16" />
<span class="theme-label">
{option.label} <span class="theme-number">[{i+1}]</span>
</span>
{#if $colorTheme === option.value}
<Icon icon="mdi:check" width="14" class="check" />
{/if}
</button>
{/each}
<div class="dropdown-divider"></div>
<div class="dropdown-header">Mode</div>
<button class="theme-option" onclick={toggleMode}>
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="16" />
<span>{$mode === 'dark' ? 'Light Mode' : 'Dark Mode'} [T]</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Backdrop for dropdowns -->
{#if themeDropdownOpen || discordPopoverOpen}
<button
class="backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
aria-label="Close"
></button>
{/if}
</nav>
<!-- Mobile menu backdrop (separate, behind the menu) -->
{#if mobileMenuOpen}
<button
class="mobile-backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { mobileMenuOpen = false; }}
aria-label="Close menu"
style="
position: fixed;
inset: 0;
top: var(--navbar-height);
background: rgba(0, 0, 0, 0.5);
z-index: 997;
border: none;
cursor: default;
"
></button>
{/if}
<!-- Mobile menu dropdown -->
{#if mobileMenuOpen}
<div
class="mobile-menu"
transition:slide={{ duration: 200 }}
style="
--bar-bg: {$themeColors.background};
--bar-bg-module: {$themeColors.backgroundLight};
--bar-border: {$themeColors.border};
--bar-text: {$themeColors.text};
--bar-primary: {$themeColors.primary};
--bar-accent: {$themeColors.accent};
--bar-muted: {$themeColors.textMuted};
"
>
<div class="mobile-nav-links">
{#each navigation as nav}
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
{#if nav.external}
<a
href={nav.path}
class="mobile-nav-link external"
target="_blank"
rel="noopener noreferrer"
onclick={closeMobileMenu}
>
<Icon icon="mdi:open-in-new" width="16" />
<span>{nav.name}</span>
</a>
{:else}
<a
href={nav.path}
class="mobile-nav-link"
class:active={isActive}
onclick={closeMobileMenu}
>
{#if nav.path === '/'}
<Icon icon="mdi:home" width="16" />
{:else if nav.path === '/portfolio'}
<Icon icon="mdi:folder-multiple" width="16" />
{:else if nav.path === '/models'}
<Icon icon="mdi:cube-outline" width="16" />
{:else if nav.path === '/projects'}
<Icon icon="mdi:trophy" width="16" />
{:else}
<Icon icon="mdi:file" width="16" />
{/if}
<span>{nav.name}</span>
</a>
{/if}
{/each}
</div>
<div class="mobile-menu-divider"></div>
<div class="mobile-theme-section">
<div class="mobile-section-header">Theme</div>
<div class="mobile-theme-options">
{#each themeOptions as option, i}
<button
class="mobile-theme-btn"
class:active={$colorTheme === option.value}
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
>
<Icon icon={getThemeIcon(option.value)} width="18" />
<span class="theme-label">{option.label} <span class="theme-number">[{i+1}]</span></span>
</button>
{/each}
</div>
<button class="mobile-mode-btn" onclick={() => { toggleMode(); closeMobileMenu(); }}>
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="18" />
<span>{$mode === 'dark' ? 'Switch to Light' : 'Switch to Dark'}</span>
</button>
</div>
</div>
{/if}