Files
Website/src/lib/components/NavbarWaybar.svelte
2025-11-28 17:43:48 +00:00

315 lines
8.7 KiB
Svelte

<script lang="ts">
import { mode, 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, colorPalette } 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());
// 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();
}, 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;
}
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
function getThemeIcon(theme: ColorTheme): string {
switch (theme) {
case 'arch': return 'mdi:arch';
case 'catppuccin': return 'solar:cat-bold';
default: return 'mdi:palette';
}
}
</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: {colorPalette.success};
--bar-warning: {colorPalette.warning};
--bar-error: {colorPalette.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) -->
<!-- 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}
<button
class="theme-option"
class:active={$colorTheme === option.value}
onclick={() => handleThemeSelect(option.value)}
>
<Icon icon={getThemeIcon(option.value)} width="16" />
<span>{option.label}</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'}</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Backdrop -->
{#if themeDropdownOpen || mobileMenuOpen}
<button
class="backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { themeDropdownOpen = false; mobileMenuOpen = false; }}
aria-label="Close"
></button>
{/if}
</nav>
<!-- 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}
<button
class="mobile-theme-btn"
class:active={$colorTheme === option.value}
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
>
<Icon icon={getThemeIcon(option.value)} width="18" />
<span>{option.label}</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}