533 lines
16 KiB
Svelte
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}
|