refactor: consolidate navigation with top nav bar and route groups

This commit is contained in:
2026-01-29 17:01:47 -06:00
parent 2bc6fbdf30
commit b58eb840f3
13 changed files with 271 additions and 109 deletions
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { page } from "$app/state";
import { Search, User } from "@lucide/svelte";
import { authStore } from "$lib/auth.svelte";
import ThemeToggle from "./ThemeToggle.svelte";
const staticTabs = [{ href: "/", label: "Search", icon: Search }] as const;
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
let profileTab = $derived({
href: authStore.isAuthenticated ? "/profile" : "/login",
label: authStore.isAuthenticated ? "Account" : "Login",
icon: User,
});
function isActive(tabHref: string): boolean {
if (tabHref === "/") return page.url.pathname === "/";
if (tabHref === "/profile") {
return APP_PREFIXES.some((p) => page.url.pathname.startsWith(p));
}
return page.url.pathname.startsWith(tabHref);
}
</script>
<nav class="w-full flex justify-center pt-5 px-5">
<div class="w-full max-w-6xl flex items-center justify-between">
<div class="flex items-center gap-1 rounded-lg bg-muted p-1">
{#each staticTabs as tab}
<a
href={tab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
{isActive(tab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
>
<tab.icon size={15} strokeWidth={2} />
{tab.label}
</a>
{/each}
<a
href={profileTab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
{isActive(profileTab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
>
<User size={15} strokeWidth={2} />
{profileTab.label}
</a>
</div>
<ThemeToggle />
</div>
</nav>
+38 -9
View File
@@ -4,36 +4,65 @@ import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
let { key, children }: { key: string; children: Snippet } = $props();
type Axis = "horizontal" | "vertical";
const DURATION = 250;
let {
key,
children,
axis = "horizontal",
inDelay = 0,
outDelay = 0,
}: {
key: string;
children: Snippet;
axis?: Axis;
inDelay?: number;
outDelay?: number;
} = $props();
const DURATION = 400;
const OFFSET = 40;
function translate(axis: Axis, value: number): string {
return axis === "vertical" ? `translateY(${value}px)` : `translateX(${value}px)`;
}
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return { duration: DURATION, easing: cubicOut, css: (t: number) => `opacity: ${t}` };
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}`,
};
}
const x = dir === "right" ? OFFSET : -OFFSET;
const offset = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
css: (t: number) => `opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
// Outgoing element is positioned absolutely so incoming flows normally
const base = "position: absolute; top: 0; left: 0; width: 100%";
if (dir === "fade") {
return { duration: DURATION, easing: cubicOut, css: (t: number) => `${base}; opacity: ${t}` };
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}`,
};
}
const x = dir === "right" ? -OFFSET : OFFSET;
const offset = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
css: (t: number) => `${base}; opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
</script>
+14 -7
View File
@@ -2,15 +2,22 @@ import { beforeNavigate } from "$app/navigation";
export type NavDirection = "left" | "right" | "fade";
/** Admin sidebar order — indexes determine slide direction for same-depth siblings */
const ADMIN_NAV_ORDER = ["/admin", "/admin/scrape-jobs", "/admin/audit-log", "/admin/users"];
/** Sidebar nav order — indexes determine slide direction for same-depth siblings */
const SIDEBAR_NAV_ORDER = [
"/profile",
"/settings",
"/admin",
"/admin/jobs",
"/admin/audit",
"/admin/users",
];
function getDepth(path: string): number {
return path.replace(/\/$/, "").split("/").filter(Boolean).length;
}
function getAdminIndex(path: string): number {
return ADMIN_NAV_ORDER.indexOf(path);
function getSidebarIndex(path: string): number {
return SIDEBAR_NAV_ORDER.indexOf(path);
}
function computeDirection(from: string, to: string): NavDirection {
@@ -20,9 +27,9 @@ function computeDirection(from: string, to: string): NavDirection {
if (toDepth > fromDepth) return "right";
if (toDepth < fromDepth) return "left";
// Same depth — use admin sidebar ordering if both are admin routes
const fromIdx = getAdminIndex(from);
const toIdx = getAdminIndex(to);
// Same depth — use sidebar ordering if both are sidebar routes
const fromIdx = getSidebarIndex(from);
const toIdx = getSidebarIndex(to);
if (fromIdx >= 0 && toIdx >= 0) {
return toIdx > fromIdx ? "right" : "left";
}
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import { ClipboardList, FileText, LayoutDashboard, LogOut, Settings, User, Users } from "@lucide/svelte";
import { onMount } from "svelte";
let { children } = $props();
onMount(async () => {
if (authStore.isLoading) {
await authStore.init();
}
});
$effect(() => {
if (authStore.state.mode === "unauthenticated") {
goto("/login");
}
});
const userItems = [
{ href: "/profile", label: "Profile", icon: User },
{ href: "/settings", label: "Settings", icon: Settings },
];
const adminItems = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/jobs", label: "Scrape Jobs", icon: ClipboardList },
{ href: "/admin/audit", label: "Audit Log", icon: FileText },
{ href: "/admin/users", label: "Users", icon: Users },
];
function isActive(href: string): boolean {
if (href === "/admin") return page.url.pathname === "/admin";
return page.url.pathname.startsWith(href);
}
</script>
{#if authStore.isLoading}
<div class="flex flex-col items-center p-5 pt-2">
<div class="w-full max-w-6xl">
<p class="text-muted-foreground py-12 text-center text-sm">Loading...</p>
</div>
</div>
{:else if !authStore.isAuthenticated}
<div class="flex flex-col items-center p-5 pt-2">
<div class="w-full max-w-6xl">
<p class="text-muted-foreground py-12 text-center text-sm">Redirecting to login...</p>
</div>
</div>
{:else}
<div class="flex flex-col items-center p-5 pt-2">
<div class="w-full max-w-6xl flex gap-8">
<!-- Inline sidebar -->
<aside class="w-48 shrink-0 pt-1">
{#if authStore.user}
<div class="mb-4 px-2">
<p class="text-sm font-medium text-foreground">{authStore.user.discordUsername}</p>
</div>
{/if}
<nav class="flex flex-col gap-0.5">
<span class="px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60 mb-0.5">User</span>
{#each userItems as item}
<a
href={item.href}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors
{isActive(item.href)
? 'text-foreground bg-muted font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<item.icon size={15} strokeWidth={2} />
{item.label}
</a>
{/each}
{#if authStore.isAdmin}
<div class="my-2 mx-2 border-t border-border"></div>
<span class="px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60 mb-0.5">Admin</span>
{#each adminItems as item}
<a
href={item.href}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors
{isActive(item.href)
? 'text-foreground bg-muted font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<item.icon size={15} strokeWidth={2} />
{item.label}
</a>
{/each}
{/if}
<div class="my-2 mx-2 border-t border-border"></div>
<button
onclick={() => authStore.logout()}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer
bg-transparent border-none text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<LogOut size={15} strokeWidth={2} />
Sign Out
</button>
</nav>
</aside>
<!-- Content -->
<main class="flex-1 min-w-0">
<PageTransition key={page.url.pathname} axis="vertical">
{@render children()}
</PageTransition>
</main>
</div>
</div>
{/if}
@@ -14,7 +14,7 @@ onMount(async () => {
});
</script>
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
<h1 class="mb-4 text-lg font-semibold text-foreground">Dashboard</h1>
{#if error}
<p class="text-destructive">{error}</p>
@@ -40,7 +40,7 @@ onMount(async () => {
</div>
</div>
<h2 class="mt-8 mb-4 text-lg font-semibold">Services</h2>
<h2 class="mt-6 mb-3 text-sm font-semibold text-foreground">Services</h2>
<div class="bg-card border-border rounded-lg border">
{#each status.services as service}
<div class="border-border flex items-center justify-between border-b px-4 py-3 last:border-b-0">
@@ -14,7 +14,7 @@ onMount(async () => {
});
</script>
<h1 class="mb-6 text-2xl font-bold">Audit Log</h1>
<h1 class="mb-4 text-lg font-semibold text-foreground">Audit Log</h1>
{#if error}
<p class="text-destructive">{error}</p>
@@ -14,7 +14,7 @@ onMount(async () => {
});
</script>
<h1 class="mb-6 text-2xl font-bold">Scrape Jobs</h1>
<h1 class="mb-4 text-lg font-semibold text-foreground">Scrape Jobs</h1>
{#if error}
<p class="text-destructive">{error}</p>
@@ -29,7 +29,7 @@ async function toggleAdmin(user: User) {
}
</script>
<h1 class="mb-6 text-2xl font-bold">Users</h1>
<h1 class="mb-4 text-lg font-semibold text-foreground">Users</h1>
{#if error}
<p class="text-destructive mb-4">{error}</p>
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import { authStore } from "$lib/auth.svelte";
</script>
<h1 class="mb-4 text-lg font-semibold text-foreground">Profile</h1>
{#if authStore.user}
<div class="bg-card border-border rounded-lg border p-4">
<div class="flex flex-col gap-3">
<div>
<p class="text-muted-foreground text-sm">Username</p>
<p class="font-medium">{authStore.user.discordUsername}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Discord ID</p>
<p class="font-medium font-mono text-sm">{authStore.user.discordId}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Role</p>
<p class="font-medium">{authStore.isAdmin ? "Admin" : "User"}</p>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,5 @@
<h1 class="mb-4 text-lg font-semibold text-foreground">Settings</h1>
<div class="bg-card border-border rounded-lg border p-4">
<p class="text-muted-foreground text-sm">No settings available yet.</p>
</div>
+13 -5
View File
@@ -3,7 +3,7 @@ import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css";
import { page } from "$app/state";
import PageTransition from "$lib/components/PageTransition.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import NavBar from "$lib/components/NavBar.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
@@ -12,6 +12,16 @@ import { onMount } from "svelte";
let { children } = $props();
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
/**
* Coarsened key so sub-route navigation within the (app) layout group
* doesn't re-trigger the root page transition — the shared layout handles its own.
*/
let transitionKey = $derived(
APP_PREFIXES.some((p) => page.url.pathname.startsWith(p)) ? "/app" : page.url.pathname
);
initNavigation();
useOverlayScrollbars(() => document.body, {
@@ -27,11 +37,9 @@ onMount(() => {
</script>
<Tooltip.Provider>
<div class="fixed top-5 right-5 z-50">
<ThemeToggle />
</div>
<NavBar />
<PageTransition key={page.url.pathname}>
<PageTransition key={transitionKey}>
{@render children()}
</PageTransition>
</Tooltip.Provider>
+1 -5
View File
@@ -201,11 +201,7 @@ function handlePageChange(newOffset: number) {
</script>
<div class="min-h-screen flex flex-col items-center p-5">
<div class="w-full max-w-6xl flex flex-col gap-6">
<!-- Title -->
<div class="text-center pt-8 pb-2">
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
</div>
<div class="w-full max-w-6xl flex flex-col gap-6 pt-2">
<!-- Search status + Filters -->
<div class="flex flex-col gap-1.5">
-78
View File
@@ -1,78 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import { ClipboardList, FileText, LayoutDashboard, LogOut, Users } from "@lucide/svelte";
import { onMount } from "svelte";
let { children } = $props();
onMount(async () => {
if (authStore.isLoading) {
await authStore.init();
}
});
$effect(() => {
if (authStore.state.mode === "unauthenticated") {
goto("/login");
}
});
const navItems = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/scrape-jobs", label: "Scrape Jobs", icon: ClipboardList },
{ href: "/admin/audit-log", label: "Audit Log", icon: FileText },
{ href: "/admin/users", label: "Users", icon: Users },
];
</script>
{#if authStore.isLoading}
<div class="flex min-h-screen items-center justify-center">
<p class="text-muted-foreground">Loading...</p>
</div>
{:else if !authStore.isAdmin}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold">Access Denied</h1>
<p class="text-muted-foreground mt-2">You do not have admin access.</p>
</div>
</div>
{:else}
<div class="flex min-h-screen">
<aside class="border-border bg-card flex w-64 flex-col border-r">
<div class="border-border border-b p-4">
<h2 class="text-lg font-semibold">Admin</h2>
{#if authStore.user}
<p class="text-muted-foreground text-sm">{authStore.user.discordUsername}</p>
{/if}
</div>
<nav class="flex-1 space-y-1 p-2">
{#each navItems as item}
<a
href={item.href}
class="hover:bg-accent flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
>
<item.icon size={18} />
{item.label}
</a>
{/each}
</nav>
<div class="border-border border-t p-2">
<button
onclick={() => authStore.logout()}
class="hover:bg-destructive/10 text-destructive flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
>
<LogOut size={18} />
Sign Out
</button>
</div>
</aside>
<main class="flex-1 overflow-auto p-6">
<PageTransition key={page.url.pathname}>
{@render children()}
</PageTransition>
</main>
</div>
{/if}