mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
feat: setup smart page transitions, fix laggy theme-aware element transitions
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="no-transition">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { navigationStore } from "$lib/stores/navigation.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import type { TransitionConfig } from "svelte/transition";
|
||||
|
||||
let { key, children }: { key: string; children: Snippet } = $props();
|
||||
|
||||
const DURATION = 250;
|
||||
const OFFSET = 40;
|
||||
|
||||
function inTransition(_node: HTMLElement): TransitionConfig {
|
||||
const dir = navigationStore.direction;
|
||||
if (dir === "fade") {
|
||||
return { duration: DURATION, easing: cubicOut, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
const x = dir === "right" ? OFFSET : -OFFSET;
|
||||
return {
|
||||
duration: DURATION,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
|
||||
};
|
||||
}
|
||||
|
||||
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}` };
|
||||
}
|
||||
const x = dir === "right" ? -OFFSET : OFFSET;
|
||||
return {
|
||||
duration: DURATION,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `${base}; opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative overflow-hidden">
|
||||
{#key key}
|
||||
<div in:inTransition out:outTransition class="w-full">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
@@ -1,7 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Select } from "bits-ui";
|
||||
import { ChevronUp, ChevronDown } from "@lucide/svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import type { Action } from "svelte/action";
|
||||
|
||||
const slideIn: Action<HTMLElement, number> = (node, direction) => {
|
||||
if (direction !== 0) {
|
||||
node.animate(
|
||||
[
|
||||
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
|
||||
{ transform: "translateX(0)", opacity: 1 },
|
||||
],
|
||||
{ duration: 200, easing: "ease-out" }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
totalCount,
|
||||
@@ -21,17 +33,8 @@ const start = $derived(offset + 1);
|
||||
const end = $derived(Math.min(offset + limit, totalCount));
|
||||
|
||||
// Track direction for slide animation
|
||||
let prevPage = $state(1);
|
||||
let direction = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const page = currentPage;
|
||||
if (page !== prevPage) {
|
||||
direction = page > prevPage ? 1 : -1;
|
||||
prevPage = page;
|
||||
}
|
||||
});
|
||||
|
||||
// 5 page slots: current-2, current-1, current, current+1, current+2
|
||||
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
|
||||
|
||||
@@ -40,6 +43,7 @@ function isSlotVisible(page: number): boolean {
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
direction = page > currentPage ? 1 : -1;
|
||||
onPageChange((page - 1) * limit);
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ const selectValue = $derived(String(currentPage));
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Page {currentPage} of {totalPages}, click to select page"
|
||||
>
|
||||
<span in:fly={{ x: direction * 20, duration: 200 }}>{currentPage}</span>
|
||||
<span use:slideIn={direction}>{currentPage}</span>
|
||||
<ChevronUp class="size-3 text-muted-foreground" />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
@@ -140,7 +144,7 @@ const selectValue = $derived(String(currentPage));
|
||||
aria-hidden={!isSlotVisible(page)}
|
||||
tabindex={isSlotVisible(page) ? 0 : -1}
|
||||
disabled={!isSlotVisible(page)}
|
||||
in:fly={{ x: direction * 20, duration: 200 }}
|
||||
use:slideIn={direction}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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"];
|
||||
|
||||
function getDepth(path: string): number {
|
||||
return path.replace(/\/$/, "").split("/").filter(Boolean).length;
|
||||
}
|
||||
|
||||
function getAdminIndex(path: string): number {
|
||||
return ADMIN_NAV_ORDER.indexOf(path);
|
||||
}
|
||||
|
||||
function computeDirection(from: string, to: string): NavDirection {
|
||||
const fromDepth = getDepth(from);
|
||||
const toDepth = getDepth(to);
|
||||
|
||||
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);
|
||||
if (fromIdx >= 0 && toIdx >= 0) {
|
||||
return toIdx > fromIdx ? "right" : "left";
|
||||
}
|
||||
|
||||
return "fade";
|
||||
}
|
||||
|
||||
class NavigationStore {
|
||||
direction: NavDirection = $state("fade");
|
||||
}
|
||||
|
||||
export const navigationStore = new NavigationStore();
|
||||
|
||||
/** Call once from root layout to start tracking navigation direction */
|
||||
export function initNavigation() {
|
||||
beforeNavigate(({ from, to }) => {
|
||||
if (!from?.url || !to?.url) return;
|
||||
navigationStore.direction = computeDirection(from.url.pathname, to.url.pathname);
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
<script lang="ts">
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
import "./layout.css";
|
||||
import { onMount } from "svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import { page } from "$app/state";
|
||||
import PageTransition from "$lib/components/PageTransition.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
||||
import { initNavigation } from "$lib/stores/navigation.svelte";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
initNavigation();
|
||||
|
||||
useOverlayScrollbars(() => document.body, {
|
||||
scrollbars: {
|
||||
autoHide: "leave",
|
||||
@@ -18,10 +23,6 @@ useOverlayScrollbars(() => document.body, {
|
||||
|
||||
onMount(() => {
|
||||
themeStore.init();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove("no-transition");
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -30,5 +31,7 @@ onMount(() => {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<PageTransition key={page.url.pathname}>
|
||||
{@render children()}
|
||||
</PageTransition>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { authStore } from "$lib/auth.svelte";
|
||||
import { LayoutDashboard, Users, ClipboardList, FileText, LogOut } from "@lucide/svelte";
|
||||
import PageTransition from "$lib/components/PageTransition.svelte";
|
||||
import { ClipboardList, FileText, LayoutDashboard, LogOut, Users } from "@lucide/svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -68,7 +70,9 @@ const navItems = [
|
||||
</div>
|
||||
</aside>
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
<PageTransition key={page.url.pathname}>
|
||||
{@render children()}
|
||||
</PageTransition>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -57,11 +57,8 @@
|
||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
border-color: var(--border);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
@@ -129,11 +126,6 @@ input[type="checkbox"]:checked::before {
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
html:not(.no-transition) body,
|
||||
html:not(.no-transition) body * {
|
||||
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
|
||||
}
|
||||
|
||||
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
|
||||
Reference in New Issue
Block a user