refactor: implement better transitions, better component layout organization, fixup prerender CSR asset ability

- Replace simple fade with shared-axis slide transitions (exit left,
enter right)
- Persist background/theme toggle across navigations using
view-transition-name
- Skip transitions for admin routes (separate layout system)
- Extend prerendered asset serving to support __data.json files with
MIME detection
- Extract TagChip component from ProjectCard for reusability
- Remove AppWrapper component in favor of direct page-main class usage
- Disable removeOptionalTags in HTML minifier to prevent invalid markup
This commit is contained in:
2026-01-13 18:51:02 -06:00
parent a849f91264
commit f881e03055
18 changed files with 263 additions and 336 deletions
+38 -25
View File
@@ -86,50 +86,63 @@ pub fn get_error_page(status_code: u16) -> Option<&'static [u8]> {
ERROR_PAGES.get_file(&filename).map(|f| f.contents()) ERROR_PAGES.get_file(&filename).map(|f| f.contents())
} }
/// Serve a prerendered page by path, if it exists. /// Serve prerendered content by path, if it exists.
/// ///
/// Prerendered pages are built by SvelteKit at compile time and embedded. /// Prerendered content is built by SvelteKit at compile time and embedded.
/// This handles various path patterns: /// This serves any file from the prerendered directory with appropriate MIME types.
/// - `/path` → looks for `path.html` ///
/// - `/path/` → looks for `path.html` or `path/index.html` /// Path resolution order:
/// 1. Exact file match (e.g., `/pgp/__data.json` → `pgp/__data.json`)
/// 2. HTML file for extensionless paths (e.g., `/pgp` → `pgp.html`)
/// 3. Index file for directory paths (e.g., `/about/` → `about/index.html`)
/// ///
/// # Arguments /// # Arguments
/// * `path` - Request path (e.g., "/pgp", "/about/") /// * `path` - Request path (e.g., "/pgp", "/pgp/__data.json")
/// ///
/// # Returns /// # Returns
/// * `Some(Response)` - HTML response if prerendered page exists /// * `Some(Response)` - Response with appropriate content-type if file exists
/// * `None` - If no prerendered page exists for this path /// * `None` - If no prerendered content exists for this path
pub fn try_serve_prerendered_page(path: &str) -> Option<Response> { pub fn try_serve_prerendered_page(path: &str) -> Option<Response> {
let path = path.strip_prefix('/').unwrap_or(path); let path = path.strip_prefix('/').unwrap_or(path);
// Try exact file match first (handles __data.json, etc.)
if let Some(file) = PRERENDERED_PAGES.get_file(path) {
return Some(serve_prerendered_file(path, file.contents()));
}
let path = path.strip_suffix('/').unwrap_or(path); let path = path.strip_suffix('/').unwrap_or(path);
// Try direct HTML file first: "pgp" -> "pgp.html" // Try as HTML file: "pgp" -> "pgp.html"
let html_filename = format!("{}.html", path); let html_path = format!("{}.html", path);
if let Some(file) = PRERENDERED_PAGES.get_file(&html_filename) { if let Some(file) = PRERENDERED_PAGES.get_file(&html_path) {
return Some(serve_html_response(file.contents())); return Some(serve_prerendered_file(&html_path, file.contents()));
} }
// Try index.html pattern: "path" -> "path/index.html" // Try index pattern: "path" -> "path/index.html"
let index_filename = format!("{}/index.html", path); let index_path = if path.is_empty() {
if let Some(file) = PRERENDERED_PAGES.get_file(&index_filename) { "index.html".to_string()
return Some(serve_html_response(file.contents())); } else {
} format!("{}/index.html", path)
};
// Try root index: "" -> "index.html" if let Some(file) = PRERENDERED_PAGES.get_file(&index_path) {
if path.is_empty() { return Some(serve_prerendered_file(&index_path, file.contents()));
if let Some(file) = PRERENDERED_PAGES.get_file("index.html") {
return Some(serve_html_response(file.contents()));
}
} }
None None
} }
fn serve_html_response(content: &'static [u8]) -> Response { fn serve_prerendered_file(path: &str, content: &'static [u8]) -> Response {
let mime_type = mime_guess::from_path(path)
.first_or_octet_stream()
.as_ref()
.to_string();
let mut headers = axum::http::HeaderMap::new(); let mut headers = axum::http::HeaderMap::new();
headers.insert( headers.insert(
header::CONTENT_TYPE, header::CONTENT_TYPE,
header::HeaderValue::from_static("text/html; charset=utf-8"), mime_type
.parse()
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")),
); );
headers.insert( headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
+54 -110
View File
@@ -35,14 +35,6 @@
var(--tw-gradient-stops) var(--tw-gradient-stops)
); );
/* Animations */
--animate-bg-fast: fade 0.5s ease-in-out 0.5s forwards;
--animate-bg: fade 2.5s ease-in-out 1.5s forwards;
--animate-fade-in: fade-in 2.5s ease-in-out forwards;
--animate-title: title 3s ease-out forwards;
--animate-fade-left: fade-left 3s ease-in-out forwards;
--animate-fade-right: fade-right 3s ease-in-out forwards;
/* Admin colors - Light mode defaults */ /* Admin colors - Light mode defaults */
--color-admin-bg: #f9fafb; --color-admin-bg: #f9fafb;
--color-admin-bg-secondary: #ffffff; --color-admin-bg-secondary: #ffffff;
@@ -55,19 +47,6 @@
--color-admin-text-muted: #6b7280; --color-admin-text-muted: #6b7280;
--color-admin-accent: #6366f1; --color-admin-accent: #6366f1;
--color-admin-accent-hover: #818cf8; --color-admin-accent-hover: #818cf8;
/* Legacy aliases for backward compatibility */
--color-admin-panel: #ffffff;
--color-admin-hover: #f3f4f6;
/* Status colors */
--color-status-active: #22c55e;
--color-status-maintained: #6366f1;
--color-status-archived: #71717a;
--color-status-hidden: #52525b;
--color-status-error: #ef4444;
--color-status-warning: #f59e0b;
--color-status-info: #06b6d4;
} }
/* Dark mode overrides */ /* Dark mode overrides */
@@ -92,78 +71,6 @@
--color-admin-text: #fafafa; --color-admin-text: #fafafa;
--color-admin-text-secondary: #a1a1aa; --color-admin-text-secondary: #a1a1aa;
--color-admin-text-muted: #71717a; --color-admin-text-muted: #71717a;
/* Legacy aliases */
--color-admin-panel: #18181b;
--color-admin-hover: #3f3f46;
}
@keyframes fade {
0% {
opacity: 0%;
}
100% {
opacity: 100%;
}
}
@keyframes fade-in {
0% {
opacity: 0%;
}
75% {
opacity: 0%;
}
100% {
opacity: 100%;
}
}
@keyframes fade-left {
0% {
transform: translateX(100%);
opacity: 0%;
}
30% {
transform: translateX(0%);
opacity: 100%;
}
100% {
opacity: 0%;
}
}
@keyframes fade-right {
0% {
transform: translateX(-100%);
opacity: 0%;
}
30% {
transform: translateX(0%);
opacity: 100%;
}
100% {
opacity: 0%;
}
}
@keyframes title {
0% {
line-height: 0%;
letter-spacing: 0.25em;
opacity: 0;
}
25% {
line-height: 0%;
opacity: 0%;
}
80% {
opacity: 100%;
}
100% {
line-height: 100%;
opacity: 100%;
}
} }
html, html,
@@ -193,41 +100,78 @@ body {
transition-duration: 0.3s !important; transition-duration: 0.3s !important;
} }
/* OverlayScrollbars theme customization */ html:not(.dark) {
.os-theme-dark, .os-scrollbar {
.os-theme-light { --os-handle-bg: rgba(0, 0, 0, 0.25) !important;
--os-handle-bg: rgb(63 63 70); --os-handle-bg-hover: rgba(0, 0, 0, 0.35) !important;
--os-handle-bg-hover: rgb(82 82 91); --os-handle-bg-active: rgba(0, 0, 0, 0.45) !important;
--os-handle-bg-active: rgb(113 113 122); }
}
html.dark {
.os-scrollbar {
--os-handle-bg: rgba(255, 255, 255, 0.35) !important;
--os-handle-bg-hover: rgba(255, 255, 255, 0.45) !important;
--os-handle-bg-active: rgba(255, 255, 255, 0.55) !important;
}
} }
.os-scrollbar-handle { .os-scrollbar-handle {
border-radius: 4px; border-radius: 4px;
} }
/* Utility class for page main wrapper */
.page-main {
@apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300;
}
/* View Transitions API - page transition animations */ /* View Transitions API - page transition animations */
@keyframes page-fade-in {
from { /* Persistent elements (background with dots, theme toggle) - excluded from transition */
opacity: 0; /* Hide old snapshots entirely so only the live element shows (prevents doubling/ghosting) */
} ::view-transition-old(background),
to { ::view-transition-old(theme-toggle) {
opacity: 1; display: none;
}
} }
@keyframes page-fade-out { ::view-transition-new(background),
::view-transition-new(theme-toggle) {
animation: none;
}
/* Page content transition - Material Design shared axis pattern */
@keyframes vt-slide-to-left {
from { from {
transform: translateX(0);
opacity: 1; opacity: 1;
} }
to { to {
transform: translateX(-20px);
opacity: 0; opacity: 0;
} }
} }
::view-transition-old(root) { @keyframes vt-slide-from-right {
animation: page-fade-out 120ms ease-out; from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
/* Only animate page content, not root (persistent UI stays static) */
::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {
animation: page-fade-in 150ms ease-in 50ms; animation: none;
}
::view-transition-old(page-content) {
animation: vt-slide-to-left 250ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(page-content) {
animation: vt-slide-from-right 250ms cubic-bezier(0.4, 0, 0.2, 1) both;
} }
+10 -1
View File
@@ -24,8 +24,17 @@
if (isDark) { if (isDark) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} }
// Set body background immediately to prevent flash // Set body background to prevent flash (deferred until body exists)
function setBodyBg() {
document.body.style.backgroundColor = isDark ? "#000000" : "#ffffff"; document.body.style.backgroundColor = isDark ? "#000000" : "#ffffff";
}
if (document.body) {
setBodyBg();
} else {
document.addEventListener("DOMContentLoaded", setBodyBg, {
once: true,
});
}
})(); })();
</script> </script>
%sveltekit.head% %sveltekit.head%
+1 -1
View File
@@ -48,7 +48,7 @@ export const handle: Handle = async ({ event, resolve }) => {
minifyJS: true, minifyJS: true,
removeAttributeQuotes: true, removeAttributeQuotes: true,
removeComments: true, removeComments: true,
removeOptionalTags: true, removeOptionalTags: false,
removeRedundantAttributes: true, removeRedundantAttributes: true,
removeScriptTypeAttributes: true, removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true, removeStyleLinkTypeAttributes: true,
-46
View File
@@ -1,46 +0,0 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { Snippet } from "svelte";
import ThemeToggle from "./ThemeToggle.svelte";
let {
class: className = "",
bgColor = "",
showThemeToggle = true,
children,
}: {
class?: string;
bgColor?: string;
showThemeToggle?: boolean;
children?: Snippet;
} = $props();
</script>
<!--
Background: Public pages get their background from root +layout.svelte for persistence.
Admin/internal pages can use bgColor prop to set their own background.
-->
{#if bgColor}
<div
class={cn(
"pointer-events-none fixed inset-0 -z-20 transition-colors duration-300",
bgColor,
)}
></div>
{/if}
<main
class={cn(
"relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300",
className,
)}
>
{#if showThemeToggle}
<div class="absolute top-5 right-6 z-50">
<ThemeToggle />
</div>
{/if}
{#if children}
{@render children()}
{/if}
</main>
@@ -83,8 +83,7 @@
<!-- SIZE: avatar (96px) + stroke (4px * 2) = 104px --> <!-- SIZE: avatar (96px) + stroke (4px * 2) = 104px -->
<!-- POSITION: -m-1 centers the stroke ring behind the avatar --> <!-- POSITION: -m-1 centers the stroke ring behind the avatar -->
<div <div
class="absolute inset-0 -m-1 rounded-full bg-zinc-100 dark:bg-zinc-900" class="absolute inset-0 -m-1 size-[104px] rounded-full bg-zinc-100 dark:bg-zinc-900"
style="width: 104px; height: 104px;"
></div> ></div>
<!-- Avatar circle --> <!-- Avatar circle -->
@@ -106,8 +105,7 @@
<!-- POSITION: bottom/right values place center on avatar circumference --> <!-- POSITION: bottom/right values place center on avatar circumference -->
<!-- For 96px avatar at 315° (bottom-right): ~4px from edge --> <!-- For 96px avatar at 315° (bottom-right): ~4px from edge -->
<div <div
class="absolute size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900" class="absolute bottom-0.5 right-0.5 size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900"
style="bottom: 2px; right: 2px;"
></div> ></div>
</div> </div>
+14 -3
View File
@@ -5,6 +5,7 @@
let { let {
class: className = "", class: className = "",
style = "",
scale = 1000, scale = 1000,
length = 10, length = 10,
spacing = 20, spacing = 20,
@@ -20,6 +21,7 @@
dotColor = [200 / 255, 200 / 255, 200 / 255] as [number, number, number], dotColor = [200 / 255, 200 / 255, 200 / 255] as [number, number, number],
}: { }: {
class?: ClassValue; class?: ClassValue;
style?: string;
scale?: number; scale?: number;
length?: number; length?: number;
spacing?: number; spacing?: number;
@@ -404,11 +406,20 @@
}); });
</script> </script>
<canvas <!-- Wrapper for background + dots canvas - single persistent unit -->
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
<!-- Background overlay -->
<div
class="absolute inset-0 bg-white dark:bg-black transition-colors duration-300"
></div>
<!-- Dots canvas -->
<canvas
bind:this={canvas} bind:this={canvas}
class={cn( class={cn(
"pointer-events-none fixed inset-0 -z-10 transition-opacity duration-1300 ease-out", "absolute inset-0 z-10 transition-opacity duration-1300 ease-out",
ready ? "opacity-100" : "opacity-0", ready ? "opacity-100" : "opacity-0",
className, className,
)} )}
></canvas> ></canvas>
</div>
+21 -70
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import TagChip from "./TagChip.svelte";
import type { AdminProject } from "$lib/admin-types"; import type { AdminProject } from "$lib/admin-types";
interface Props { interface Props {
@@ -12,12 +13,14 @@
let { project, class: className }: Props = $props(); let { project, class: className }: Props = $props();
// Prefer demo URL, fallback to GitHub repo (use $derived to react to project changes) // Prefer demo URL, fallback to GitHub repo
const projectUrl = $derived( const projectUrl = $derived(
project.demoUrl || project.demoUrl ||
(project.githubRepo ? `https://github.com/${project.githubRepo}` : null), (project.githubRepo ? `https://github.com/${project.githubRepo}` : null),
); );
const isLink = $derived(!!projectUrl);
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -36,71 +39,30 @@
} }
</script> </script>
{#if projectUrl} <svelte:element
<a this={isLink ? "a" : "div"}
href={projectUrl} href={isLink ? projectUrl : undefined}
target="_blank" target={isLink ? "_blank" : undefined}
rel="noopener noreferrer" rel={isLink ? "noopener noreferrer" : undefined}
class={cn(
"group flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800/70",
className,
)}
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<h3
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100 transition-colors group-hover:text-zinc-950 dark:group-hover:text-white"
>
{project.name}
</h3>
<span
class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300"
>
{formatDate(project.updatedAt)}
</span>
</div>
<p
class="line-clamp-3 sm:text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
>
{project.shortDescription}
</p>
</div>
<div class="mt-auto flex flex-wrap gap-1">
{#each project.tags as tag (tag.name)}
<!-- TODO: Add link to project search with tag filtering -->
<span
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each}
</div>
</a>
{:else}
<div
class={cn( class={cn(
"flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3", "flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3",
isLink &&
"group transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800/70",
className, className,
)} )}
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h3 <h3
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100" class={cn(
"truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100",
isLink &&
"transition-colors group-hover:text-zinc-950 dark:group-hover:text-white",
)}
> >
{project.name} {project.name}
</h3> </h3>
<span <span class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300">
class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300"
>
{formatDate(project.updatedAt)} {formatDate(project.updatedAt)}
</span> </span>
</div> </div>
@@ -111,21 +73,10 @@
</p> </p>
</div> </div>
<!-- TODO: Add link to project search with tag filtering -->
<div class="mt-auto flex flex-wrap gap-1"> <div class="mt-auto flex flex-wrap gap-1">
{#each project.tags as tag (tag.name)} {#each project.tags as tag (tag.name)}
<span <TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each} {/each}
</div> </div>
</div> </svelte:element>
{/if}
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from "$lib/utils";
interface Props {
name: string;
color?: string;
iconSvg?: string;
class?: string;
}
let { name, color, iconSvg, class: className }: Props = $props();
</script>
<span
class={cn(
"inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3",
className,
)}
style="border-left-color: #{color || '06b6d4'}"
>
{#if iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html iconSvg}
</span>
{/if}
<span>{name}</span>
</span>
+10 -12
View File
@@ -204,9 +204,12 @@
<!-- Selected icon preview --> <!-- Selected icon preview -->
{#if selectedIcon} {#if selectedIcon}
<div <div
class="flex items-center gap-3 rounded-md border border-admin-border bg-admin-panel p-3" class="flex items-center gap-3 rounded-md border border-admin-border bg-admin-bg-secondary p-3"
>
<div
class="flex size-10 items-center justify-center rounded bg-admin-bg"
data-icon-container
> >
<div class="flex size-10 items-center justify-center rounded bg-admin-bg">
{#if selectedIconSvg} {#if selectedIconSvg}
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html selectedIconSvg} {@html selectedIconSvg}
@@ -222,7 +225,7 @@
<button <button
type="button" type="button"
onclick={clearSelection} onclick={clearSelection}
class="rounded px-2 py-1 text-sm text-admin-text-muted hover:bg-admin-hover hover:text-admin-text" class="rounded px-2 py-1 text-sm text-admin-text-muted hover:bg-admin-surface-hover hover:text-admin-text"
> >
Clear Clear
</button> </button>
@@ -284,12 +287,12 @@
<button <button
type="button" type="button"
data-icon-id={result.identifier} data-icon-id={result.identifier}
class="group relative flex size-12 items-center justify-center rounded hover:bg-admin-hover" class="group relative flex size-12 items-center justify-center rounded hover:bg-admin-surface-hover"
onclick={() => selectIcon(result.identifier)} onclick={() => selectIcon(result.identifier)}
title={result.identifier} title={result.identifier}
> >
<!-- Lazy load icon SVG via IntersectionObserver --> <!-- Lazy load icon SVG via IntersectionObserver -->
<div class="size-9 text-admin-text"> <div class="size-9 text-admin-text" data-icon-container>
{#if cachedSvg} {#if cachedSvg}
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html cachedSvg} {@html cachedSvg}
@@ -338,14 +341,9 @@
<!-- Could add "star" button to favorite frequently used icons --> <!-- Could add "star" button to favorite frequently used icons -->
<style> <style>
/* Ensure SVG icons inherit size */ /* Ensure dynamically-injected SVG icons fill their container */
:global(.size-9 svg) { [data-icon-container] :global(svg) {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
:global(.size-10 svg) {
width: 1.5rem;
height: 1.5rem;
}
</style> </style>
+7 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
interface Props { interface Props {
@@ -11,6 +12,7 @@
onconfirm?: () => void; onconfirm?: () => void;
oncancel?: () => void; oncancel?: () => void;
children?: import("svelte").Snippet; children?: import("svelte").Snippet;
class?: string;
} }
let { let {
@@ -23,6 +25,7 @@
onconfirm, onconfirm,
oncancel, oncancel,
children, children,
class: className,
}: Props = $props(); }: Props = $props();
function handleCancel() { function handleCancel() {
@@ -51,7 +54,10 @@
tabindex="-1" tabindex="-1"
> >
<div <div
class="relative w-full max-w-md rounded-xl bg-admin-surface border border-admin-border p-8 shadow-xl shadow-black/20 dark:shadow-black/50" class={cn(
"relative w-full max-w-md rounded-xl bg-admin-surface border border-admin-border p-8 shadow-xl shadow-black/20 dark:shadow-black/50",
className,
)}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
+2 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
const status = $derived($page.status); const status = $derived($page.status);
@@ -21,7 +20,7 @@
<title>{status} - {message}</title> <title>{status} - {message}</title>
</svelte:head> </svelte:head>
<AppWrapper> <main class="page-main">
<div class="flex min-h-screen items-center justify-center"> <div class="flex min-h-screen items-center justify-center">
<div class="mx-4 max-w-2xl text-center"> <div class="mx-4 max-w-2xl text-center">
<h1 class="mb-4 font-hanken text-8xl text-text-secondary">{status}</h1> <h1 class="mb-4 font-hanken text-8xl text-text-secondary">{status}</h1>
@@ -36,4 +35,4 @@
{/if} {/if}
</div> </div>
</div> </div>
</AppWrapper> </main>
+23 -5
View File
@@ -10,6 +10,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { onNavigate } from "$app/navigation"; import { onNavigate } from "$app/navigation";
import Dots from "$lib/components/Dots.svelte"; import Dots from "$lib/components/Dots.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
let { children, data } = $props(); let { children, data } = $props();
@@ -40,6 +41,13 @@
return; return;
} }
// Skip transitions for admin routes (they have their own layout/style)
const fromAdmin = navigation.from?.url.pathname.startsWith("/admin");
const toAdmin = navigation.to?.url.pathname.startsWith("/admin");
if (fromAdmin || toAdmin) {
return;
}
return new Promise((resolve) => { return new Promise((resolve) => {
document.startViewTransition(async () => { document.startViewTransition(async () => {
resolve(); resolve();
@@ -57,6 +65,7 @@
scrollbars: { scrollbars: {
autoHide: "leave", autoHide: "leave",
autoHideDelay: 800, autoHideDelay: 800,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
}, },
}); });
@@ -92,12 +101,21 @@
</svelte:head> </svelte:head>
<!-- Persistent background layer - only for public routes --> <!-- Persistent background layer - only for public routes -->
<!-- These elements have view-transition-name to exclude them from page transitions -->
{#if showGlobalBackground} {#if showGlobalBackground}
<!-- Dots component includes both background overlay and animated dots -->
<Dots style="view-transition-name: background" />
<!-- Theme toggle - persistent across page transitions -->
<div <div
class="pointer-events-none fixed inset-0 -z-20 bg-white dark:bg-black transition-colors duration-300" class="fixed top-5 right-6 z-50"
></div> style="view-transition-name: theme-toggle"
<Dots /> >
<ThemeToggle />
</div>
{/if} {/if}
<!-- Page content - transitions handled by View Transitions API --> <!-- Page content wrapper - this is what transitions between pages -->
{@render children()} <div style="view-transition-name: page-content">
{@render children()}
</div>
+2 -3
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import AppWrapper from "$lib/components/AppWrapper.svelte";
import ProjectCard from "$lib/components/ProjectCard.svelte"; import ProjectCard from "$lib/components/ProjectCard.svelte";
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte"; import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
@@ -31,7 +30,7 @@
let discordUsername = $state(""); let discordUsername = $state("");
</script> </script>
<AppWrapper class="overflow-x-hidden font-schibsted"> <main class="page-main overflow-x-hidden font-schibsted">
<div class="flex items-center flex-col pt-14"> <div class="flex items-center flex-col pt-14">
<div <div
class="max-w-2xl mx-4 border-b border-zinc-200 dark:border-zinc-700 divide-y divide-zinc-200 dark:divide-zinc-700 sm:mx-6" class="max-w-2xl mx-4 border-b border-zinc-200 dark:border-zinc-700 divide-y divide-zinc-200 dark:divide-zinc-700 sm:mx-6"
@@ -134,6 +133,6 @@
</div> </div>
</div> </div>
</div> </div>
</AppWrapper> </main>
<DiscordProfileModal bind:open={discordModalOpen} username={discordUsername} /> <DiscordProfileModal bind:open={discordModalOpen} username={discordUsername} />
+5 -5
View File
@@ -3,7 +3,6 @@
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import Sidebar from "$lib/components/admin/Sidebar.svelte"; import Sidebar from "$lib/components/admin/Sidebar.svelte";
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { authStore } from "$lib/stores/auth.svelte"; import { authStore } from "$lib/stores/auth.svelte";
import { getAdminStats } from "$lib/api"; import { getAdminStats } from "$lib/api";
import type { AdminStats } from "$lib/admin-types"; import type { AdminStats } from "$lib/admin-types";
@@ -54,8 +53,10 @@
<!-- Login page has no sidebar --> <!-- Login page has no sidebar -->
{@render children()} {@render children()}
{:else} {:else}
<!-- Admin layout with sidebar and dots shader --> <!-- Admin layout with sidebar -->
<AppWrapper bgColor="bg-admin-bg" showThemeToggle={false}> <div
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
></div>
<Sidebar <Sidebar
projectCount={stats?.totalProjects ?? 0} projectCount={stats?.totalProjects ?? 0}
tagCount={stats?.totalTags ?? 0} tagCount={stats?.totalTags ?? 0}
@@ -63,10 +64,9 @@
/> />
<!-- Main content area --> <!-- Main content area -->
<main class="lg:pl-64"> <main class="lg:pl-64 min-h-screen text-admin-text">
<div class="px-4 py-8 sm:px-6 lg:px-8"> <div class="px-4 py-8 sm:px-6 lg:px-8">
{@render children()} {@render children()}
</div> </div>
</main> </main>
</AppWrapper>
{/if} {/if}
+5 -3
View File
@@ -4,7 +4,6 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import Button from "$lib/components/admin/Button.svelte"; import Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte"; import Input from "$lib/components/admin/Input.svelte";
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { authStore } from "$lib/stores/auth.svelte"; import { authStore } from "$lib/stores/auth.svelte";
let username = $state(""); let username = $state("");
@@ -39,7 +38,10 @@
<title>Admin Login | xevion.dev</title> <title>Admin Login | xevion.dev</title>
</svelte:head> </svelte:head>
<AppWrapper> <div
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
></div>
<main class="page-main text-admin-text">
<div class="flex min-h-screen items-center justify-center px-4"> <div class="flex min-h-screen items-center justify-center px-4">
<div class="w-full max-w-md space-y-4"> <div class="w-full max-w-md space-y-4">
<!-- Login Form --> <!-- Login Form -->
@@ -95,4 +97,4 @@
</div> </div>
</div> </div>
</div> </div>
</AppWrapper> </main>
+2 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import AppWrapper from "$components/AppWrapper.svelte";
let { data } = $props(); let { data } = $props();
@@ -13,7 +12,7 @@
<title>{title}</title> <title>{title}</title>
</svelte:head> </svelte:head>
<AppWrapper> <main class="page-main">
<div class="min-h-screen flex items-center justify-center"> <div class="min-h-screen flex items-center justify-center">
<div class="mx-4 max-w-3xl text-center"> <div class="mx-4 max-w-3xl text-center">
<h1 class="text-6xl sm:text-9xl font-hanken font-black text-zinc-200"> <h1 class="text-6xl sm:text-9xl font-hanken font-black text-zinc-200">
@@ -34,4 +33,4 @@
{/if} {/if}
</div> </div>
</div> </div>
</AppWrapper> </main>
+2 -4
View File
@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { OverlayScrollbarsComponent } from "overlayscrollbars-svelte"; import { OverlayScrollbarsComponent } from "overlayscrollbars-svelte";
import "overlayscrollbars/overlayscrollbars.css";
import IconDownload from "~icons/material-symbols/download-rounded"; import IconDownload from "~icons/material-symbols/download-rounded";
import IconCopy from "~icons/material-symbols/content-copy-rounded"; import IconCopy from "~icons/material-symbols/content-copy-rounded";
import IconCheck from "~icons/material-symbols/check-rounded"; import IconCheck from "~icons/material-symbols/check-rounded";
@@ -56,7 +54,7 @@
/> />
</svelte:head> </svelte:head>
<AppWrapper class="overflow-x-hidden font-schibsted"> <main class="page-main overflow-x-hidden font-schibsted">
<div class="flex items-center flex-col pt-14 pb-20 px-4 sm:px-6"> <div class="flex items-center flex-col pt-14 pb-20 px-4 sm:px-6">
<div class="max-w-2xl w-full"> <div class="max-w-2xl w-full">
<!-- Header --> <!-- Header -->
@@ -190,4 +188,4 @@
</div> </div>
</div> </div>
</div> </div>
</AppWrapper> </main>