mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 06:23:37 -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>
|
<!doctype html>
|
||||||
<html lang="en" class="no-transition">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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">
|
<script lang="ts">
|
||||||
import { Select } from "bits-ui";
|
import { Select } from "bits-ui";
|
||||||
import { ChevronUp, ChevronDown } from "@lucide/svelte";
|
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 {
|
let {
|
||||||
totalCount,
|
totalCount,
|
||||||
@@ -21,17 +33,8 @@ const start = $derived(offset + 1);
|
|||||||
const end = $derived(Math.min(offset + limit, totalCount));
|
const end = $derived(Math.min(offset + limit, totalCount));
|
||||||
|
|
||||||
// Track direction for slide animation
|
// Track direction for slide animation
|
||||||
let prevPage = $state(1);
|
|
||||||
let direction = $state(0);
|
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
|
// 5 page slots: current-2, current-1, current, current+1, current+2
|
||||||
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
|
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) {
|
function goToPage(page: number) {
|
||||||
|
direction = page > currentPage ? 1 : -1;
|
||||||
onPageChange((page - 1) * limit);
|
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"
|
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"
|
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" />
|
<ChevronUp class="size-3 text-muted-foreground" />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
@@ -140,8 +144,8 @@ const selectValue = $derived(String(currentPage));
|
|||||||
aria-hidden={!isSlotVisible(page)}
|
aria-hidden={!isSlotVisible(page)}
|
||||||
tabindex={isSlotVisible(page) ? 0 : -1}
|
tabindex={isSlotVisible(page) ? 0 : -1}
|
||||||
disabled={!isSlotVisible(page)}
|
disabled={!isSlotVisible(page)}
|
||||||
in:fly={{ x: direction * 20, duration: 200 }}
|
use:slideIn={direction}
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import "overlayscrollbars/overlayscrollbars.css";
|
import "overlayscrollbars/overlayscrollbars.css";
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import { onMount } from "svelte";
|
import { page } from "$app/state";
|
||||||
import { Tooltip } from "bits-ui";
|
import PageTransition from "$lib/components/PageTransition.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
import { themeStore } from "$lib/stores/theme.svelte";
|
|
||||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.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();
|
let { children } = $props();
|
||||||
|
|
||||||
|
initNavigation();
|
||||||
|
|
||||||
useOverlayScrollbars(() => document.body, {
|
useOverlayScrollbars(() => document.body, {
|
||||||
scrollbars: {
|
scrollbars: {
|
||||||
autoHide: "leave",
|
autoHide: "leave",
|
||||||
@@ -18,10 +23,6 @@ useOverlayScrollbars(() => document.body, {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
themeStore.init();
|
themeStore.init();
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.documentElement.classList.remove("no-transition");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -30,5 +31,7 @@ onMount(() => {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{@render children()}
|
<PageTransition key={page.url.pathname}>
|
||||||
|
{@render children()}
|
||||||
|
</PageTransition>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { authStore } from "$lib/auth.svelte";
|
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();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -68,7 +70,9 @@ const navItems = [
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="flex-1 overflow-auto p-6">
|
<main class="flex-1 overflow-auto p-6">
|
||||||
{@render children()}
|
<PageTransition key={page.url.pathname}>
|
||||||
|
{@render children()}
|
||||||
|
</PageTransition>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -57,11 +57,8 @@
|
|||||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
border-color: var(--border);
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans);
|
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%);
|
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 Transitions API - disable default cross-fade so JS can animate clip-path */
|
||||||
::view-transition-old(root),
|
::view-transition-old(root),
|
||||||
::view-transition-new(root) {
|
::view-transition-new(root) {
|
||||||
|
|||||||
Reference in New Issue
Block a user