mirror of
https://github.com/Xevion/banner.git
synced 2026-01-30 20:23:34 -06:00
refactor: consolidate navigation with top nav bar and route groups
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
+1
-1
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user