style(nav): add bottom nav, hamburger page nav

This commit is contained in:
Sam Chau
2026-01-28 08:38:03 +10:30
parent 8db58d36dc
commit 2f17e786fe
7 changed files with 2096 additions and 25 deletions

1945
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
import { writable } from 'svelte/store';
function createMobileNavStore() {
const { subscribe, set, update } = writable(false);
return {
subscribe,
open: () => set(true),
close: () => set(false),
toggle: () => update((v) => !v)
};
}
export const mobileNavOpen = createMobileNavStore();

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { page } from '$app/stores';
import { navIconStore } from '$stores/navIcons';
import {
Database,
MonitorPlay,
Sliders,
Palette,
Settings,
Microscope,
Tag,
Clock
} from 'lucide-svelte';
type NavItem = {
href: string;
label: string;
shortLabel?: string;
icon: typeof Database;
emoji: string;
priority: 'always' | 'medium' | 'low';
};
const items: NavItem[] = [
{ href: '/databases', label: 'Databases', icon: Database, emoji: '💾', priority: 'always' },
{ href: '/arr', label: 'Arrs', icon: MonitorPlay, emoji: '📺', priority: 'always' },
{ href: '/quality-profiles', label: 'Profiles', icon: Sliders, emoji: '⚡', priority: 'always' },
{ href: '/custom-formats', label: 'Formats', icon: Palette, emoji: '🎨', priority: 'always' },
{ href: '/settings', label: 'Settings', icon: Settings, emoji: '⚙️', priority: 'always' },
{ href: '/regular-expressions', label: 'Regex', icon: Microscope, emoji: '🔬', priority: 'medium' },
{ href: '/media-management', label: 'Media', icon: Tag, emoji: '🏷️', priority: 'low' },
{ href: '/delay-profiles', label: 'Delay', icon: Clock, emoji: '⏱️', priority: 'low' }
];
$: useEmoji = $navIconStore === 'emoji';
$: pathname = $page.url.pathname;
function isActive(href: string, currentPath: string): boolean {
if (href === '/') return currentPath === '/';
return currentPath.startsWith(href);
}
</script>
<nav
class="fixed bottom-0 left-0 right-0 z-50 border-t border-neutral-200 bg-neutral-50 pb-[env(safe-area-inset-bottom)] md:hidden dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="flex items-center justify-around px-1">
{#each items as item}
{@const active = isActive(item.href, pathname)}
<a
href={item.href}
class="flex flex-col items-center justify-center py-2 transition-colors
{item.priority === 'medium' ? 'hidden sm:flex' : ''}
{item.priority === 'low' ? 'hidden' : ''}
{active
? 'text-accent-600 dark:text-accent-400'
: 'text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200'}"
>
{#if useEmoji}
<span class="text-xl">{item.emoji}</span>
{:else}
<svelte:component this={item.icon} size={20} strokeWidth={active ? 2.5 : 2} />
{/if}
<span class="mt-0.5 text-[10px] font-medium">{item.shortLabel ?? item.label}</span>
</a>
{/each}
</div>
</nav>

View File

@@ -1,24 +1,33 @@
<script lang="ts">
import AccentPicker from './accentPicker.svelte';
import ThemeToggle from './themeToggle.svelte';
import { Menu } from 'lucide-svelte';
import { mobileNavOpen } from '$stores/mobileNav';
import logo from '$assets/logo-512.png';
export let collapsed: boolean = false;
</script>
<nav
class="fixed top-0 left-0 z-50 w-72 border-r border-b border-neutral-200 bg-neutral-50 transition-transform duration-200 dark:border-neutral-800 dark:bg-neutral-900"
class:-translate-x-[calc(100%-24px)]={collapsed}
class="fixed top-0 left-0 z-50 w-full border-b border-neutral-200 bg-neutral-50 md:w-80 md:border-r md:transition-transform md:duration-200 dark:border-neutral-800 dark:bg-neutral-900 {collapsed ? 'md:-translate-x-[calc(100%-24px)]' : ''}"
>
<div class="flex items-center justify-between px-4 py-4">
<!-- Left: Brand name with logo -->
<div class="ml-4 flex items-center gap-2">
<img src={logo} alt="Profilarr logo" class="h-5 w-5 translate-y-[2px]" />
<div class="text-xl font-bold text-neutral-900 dark:text-neutral-100">profilarr</div>
<!-- Left: Hamburger (mobile) + Brand name with logo (desktop) -->
<div class="flex items-center gap-2">
<button
type="button"
on:click={() => mobileNavOpen.open()}
class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 md:hidden dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
aria-label="Open menu"
>
<Menu size={20} />
</button>
<img src={logo} alt="Profilarr logo" class="hidden h-5 w-5 translate-y-[2px] md:ml-4 md:block" />
<div class="hidden text-xl font-bold text-neutral-900 md:block dark:text-neutral-100">profilarr</div>
</div>
<!-- Right: Accent picker and Theme toggle -->
<div class="flex items-center gap-1">
<!-- Right: Accent picker and Theme toggle (desktop only) -->
<div class="hidden items-center gap-1 md:flex">
<AccentPicker />
<ThemeToggle />
</div>

View File

@@ -2,19 +2,58 @@
import Group from './group.svelte';
import GroupItem from './groupItem.svelte';
import Version from './version.svelte';
import { Home, Sliders, Palette, Microscope, Tag, Clock, Settings } from 'lucide-svelte';
import { Home, Sliders, Palette, Microscope, Tag, Clock, Settings, X } from 'lucide-svelte';
import { navIconStore } from '$stores/navIcons';
import { mobileNavOpen } from '$stores/mobileNav';
import { page } from '$app/stores';
import logo from '$assets/logo-512.png';
export let collapsed: boolean = false;
export let version: string = '';
$: useEmoji = $navIconStore === 'emoji';
// Close mobile nav when page changes
$: $page.url.pathname, mobileNavOpen.close();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && $mobileNavOpen) mobileNavOpen.close();
}
</script>
<svelte:window on:keydown={handleKeydown} />
<!-- Mobile backdrop -->
{#if $mobileNavOpen}
<button
type="button"
class="fixed inset-0 z-[60] bg-black/50 md:hidden"
on:click={() => mobileNavOpen.close()}
aria-label="Close menu"
></button>
{/if}
<nav
class="fixed top-16 left-0 flex h-[calc(100vh-4rem)] w-72 flex-col border-r border-neutral-200 bg-neutral-50 transition-transform duration-200 dark:border-neutral-800 dark:bg-neutral-900"
class:-translate-x-[calc(100%-24px)]={collapsed}
class="fixed top-0 left-0 z-[70] flex h-full w-[90vw] flex-col border-r border-neutral-200 bg-neutral-50 transition-transform duration-200 dark:border-neutral-800 dark:bg-neutral-900
{$mobileNavOpen ? 'translate-x-0' : '-translate-x-full'}
md:top-16 md:h-[calc(100vh-4rem)] md:w-80 md:translate-x-0 {collapsed ? 'md:-translate-x-[calc(100%-24px)]' : ''}"
>
<!-- Mobile header with logo and close button -->
<div class="flex items-center justify-between border-b border-neutral-200 py-4 pl-8 pr-4 md:hidden dark:border-neutral-800">
<div class="flex items-center gap-2">
<img src={logo} alt="Profilarr logo" class="h-5 w-5" />
<span class="text-xl font-bold text-neutral-900 dark:text-neutral-100">profilarr</span>
</div>
<button
type="button"
on:click={() => mobileNavOpen.close()}
class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<Group
label={useEmoji ? '🏠 Home' : 'Home'}
@@ -85,7 +124,7 @@
<GroupItem label="About" href="/settings/about" />
<GroupItem label="Log Out" href="/auth/logout" />
</Group>
</div>
<Version {version} />
<Version {version} />
</div>
</nav>

View File

@@ -9,10 +9,9 @@
const showVersion = shouldShowVersion();
</script>
<div class="p-4">
<div
class="flex items-center gap-2.5 rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-700"
>
<div
class="mt-2 flex items-center gap-2.5 rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-700"
>
<img src={logo} alt="Profilarr logo" class="h-5 w-5 flex-shrink-0" />
<div class="flex-1">
@@ -24,4 +23,3 @@
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,8 @@
import logo from '$assets/logo-512.png';
import Navbar from '$ui/navigation/navbar/navbar.svelte';
import PageNav from '$ui/navigation/pageNav/pageNav.svelte';
import AlertContainer from '$alerts/AlertContainer.svelte';
import BottomNav from '$ui/navigation/bottomNav/BottomNav.svelte';
import AlertContainer from '$alerts/AlertContainer.svelte';
import { sidebarCollapsed } from '$lib/client/stores/sidebar';
import { page } from '$app/stores';
@@ -21,16 +22,17 @@
{#if !isAuthPage}
<Navbar collapsed={$sidebarCollapsed} />
<PageNav collapsed={$sidebarCollapsed} version={data.version} />
<BottomNav />
{/if}
<AlertContainer />
{#if !isAuthPage}
<!-- Sidebar collapse toggle button -->
<!-- Sidebar collapse toggle button (desktop only) -->
<button
type="button"
on:click={() => sidebarCollapsed.toggle()}
class="fixed top-16 z-50 flex h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
style="left: {$sidebarCollapsed ? '24px' : '288px'}"
class="fixed top-16 z-50 hidden h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 md:flex dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
style="left: {$sidebarCollapsed ? '24px' : '320px'}"
aria-label={$sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<div class="flex flex-col gap-[3px]">
@@ -41,6 +43,6 @@
</button>
{/if}
<main class={isAuthPage ? '' : `transition-all duration-200 ${$sidebarCollapsed ? 'pl-[24px]' : 'pl-72'}`}>
<main class={isAuthPage ? '' : `pt-16 pb-16 transition-all duration-200 md:pb-0 md:pt-0 ${$sidebarCollapsed ? 'md:pl-[24px]' : 'md:pl-80'}`}>
<slot />
</main>