From f881e030551d94c5c50fb8bc1e9cfbdf288ca6a8 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 13 Jan 2026 18:51:02 -0600 Subject: [PATCH] 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 --- src/assets.rs | 63 ++++--- web/src/app.css | 164 ++++++------------ web/src/app.html | 13 +- web/src/hooks.server.ts | 2 +- web/src/lib/components/AppWrapper.svelte | 46 ----- .../lib/components/DiscordProfileModal.svelte | 6 +- web/src/lib/components/Dots.svelte | 27 ++- web/src/lib/components/ProjectCard.svelte | 133 +++++--------- web/src/lib/components/TagChip.svelte | 28 +++ .../lib/components/admin/IconPicker.svelte | 22 ++- web/src/lib/components/admin/Modal.svelte | 8 +- web/src/routes/+error.svelte | 5 +- web/src/routes/+layout.svelte | 28 ++- web/src/routes/+page.svelte | 5 +- web/src/routes/admin/+layout.svelte | 30 ++-- web/src/routes/admin/login/+page.svelte | 8 +- web/src/routes/errors/[code]/+page.svelte | 5 +- web/src/routes/pgp/+page.svelte | 6 +- 18 files changed, 263 insertions(+), 336 deletions(-) delete mode 100644 web/src/lib/components/AppWrapper.svelte create mode 100644 web/src/lib/components/TagChip.svelte diff --git a/src/assets.rs b/src/assets.rs index 0ac124a..be39bc2 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -86,50 +86,63 @@ pub fn get_error_page(status_code: u16) -> Option<&'static [u8]> { 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. -/// This handles various path patterns: -/// - `/path` → looks for `path.html` -/// - `/path/` → looks for `path.html` or `path/index.html` +/// Prerendered content is built by SvelteKit at compile time and embedded. +/// This serves any file from the prerendered directory with appropriate MIME types. +/// +/// 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 -/// * `path` - Request path (e.g., "/pgp", "/about/") +/// * `path` - Request path (e.g., "/pgp", "/pgp/__data.json") /// /// # Returns -/// * `Some(Response)` - HTML response if prerendered page exists -/// * `None` - If no prerendered page exists for this path +/// * `Some(Response)` - Response with appropriate content-type if file exists +/// * `None` - If no prerendered content exists for this path pub fn try_serve_prerendered_page(path: &str) -> Option { 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); - // Try direct HTML file first: "pgp" -> "pgp.html" - let html_filename = format!("{}.html", path); - if let Some(file) = PRERENDERED_PAGES.get_file(&html_filename) { - return Some(serve_html_response(file.contents())); + // Try as HTML file: "pgp" -> "pgp.html" + let html_path = format!("{}.html", path); + if let Some(file) = PRERENDERED_PAGES.get_file(&html_path) { + return Some(serve_prerendered_file(&html_path, file.contents())); } - // Try index.html pattern: "path" -> "path/index.html" - let index_filename = format!("{}/index.html", path); - if let Some(file) = PRERENDERED_PAGES.get_file(&index_filename) { - return Some(serve_html_response(file.contents())); - } - - // Try root index: "" -> "index.html" - if path.is_empty() { - if let Some(file) = PRERENDERED_PAGES.get_file("index.html") { - return Some(serve_html_response(file.contents())); - } + // Try index pattern: "path" -> "path/index.html" + let index_path = if path.is_empty() { + "index.html".to_string() + } else { + format!("{}/index.html", path) + }; + if let Some(file) = PRERENDERED_PAGES.get_file(&index_path) { + return Some(serve_prerendered_file(&index_path, file.contents())); } 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(); headers.insert( 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( header::CACHE_CONTROL, diff --git a/web/src/app.css b/web/src/app.css index c1465f0..bc223e7 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -35,14 +35,6 @@ 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 */ --color-admin-bg: #f9fafb; --color-admin-bg-secondary: #ffffff; @@ -55,19 +47,6 @@ --color-admin-text-muted: #6b7280; --color-admin-accent: #6366f1; --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 */ @@ -92,78 +71,6 @@ --color-admin-text: #fafafa; --color-admin-text-secondary: #a1a1aa; --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, @@ -193,41 +100,78 @@ body { transition-duration: 0.3s !important; } -/* OverlayScrollbars theme customization */ -.os-theme-dark, -.os-theme-light { - --os-handle-bg: rgb(63 63 70); - --os-handle-bg-hover: rgb(82 82 91); - --os-handle-bg-active: rgb(113 113 122); +html:not(.dark) { + .os-scrollbar { + --os-handle-bg: rgba(0, 0, 0, 0.25) !important; + --os-handle-bg-hover: rgba(0, 0, 0, 0.35) !important; + --os-handle-bg-active: rgba(0, 0, 0, 0.45) !important; + } +} + +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 { 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 */ -@keyframes page-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } + +/* Persistent elements (background with dots, theme toggle) - excluded from transition */ +/* Hide old snapshots entirely so only the live element shows (prevents doubling/ghosting) */ +::view-transition-old(background), +::view-transition-old(theme-toggle) { + 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 { + transform: translateX(0); opacity: 1; } to { + transform: translateX(-20px); opacity: 0; } } -::view-transition-old(root) { - animation: page-fade-out 120ms ease-out; +@keyframes vt-slide-from-right { + 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) { - 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; } diff --git a/web/src/app.html b/web/src/app.html index ade4086..e642e38 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -24,8 +24,17 @@ if (isDark) { document.documentElement.classList.add("dark"); } - // Set body background immediately to prevent flash - document.body.style.backgroundColor = isDark ? "#000000" : "#ffffff"; + // Set body background to prevent flash (deferred until body exists) + function setBodyBg() { + document.body.style.backgroundColor = isDark ? "#000000" : "#ffffff"; + } + if (document.body) { + setBodyBg(); + } else { + document.addEventListener("DOMContentLoaded", setBodyBg, { + once: true, + }); + } })(); %sveltekit.head% diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 20c406c..89a0275 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -48,7 +48,7 @@ export const handle: Handle = async ({ event, resolve }) => { minifyJS: true, removeAttributeQuotes: true, removeComments: true, - removeOptionalTags: true, + removeOptionalTags: false, removeRedundantAttributes: true, removeScriptTypeAttributes: true, removeStyleLinkTypeAttributes: true, diff --git a/web/src/lib/components/AppWrapper.svelte b/web/src/lib/components/AppWrapper.svelte deleted file mode 100644 index 84ce400..0000000 --- a/web/src/lib/components/AppWrapper.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - -{#if bgColor} -
-{/if} - -
- {#if showThemeToggle} -
- -
- {/if} - {#if children} - {@render children()} - {/if} -
diff --git a/web/src/lib/components/DiscordProfileModal.svelte b/web/src/lib/components/DiscordProfileModal.svelte index c870815..049f5dc 100644 --- a/web/src/lib/components/DiscordProfileModal.svelte +++ b/web/src/lib/components/DiscordProfileModal.svelte @@ -83,8 +83,7 @@
@@ -106,8 +105,7 @@
diff --git a/web/src/lib/components/Dots.svelte b/web/src/lib/components/Dots.svelte index 9b78efe..a20692a 100644 --- a/web/src/lib/components/Dots.svelte +++ b/web/src/lib/components/Dots.svelte @@ -5,6 +5,7 @@ let { class: className = "", + style = "", scale = 1000, length = 10, spacing = 20, @@ -20,6 +21,7 @@ dotColor = [200 / 255, 200 / 255, 200 / 255] as [number, number, number], }: { class?: ClassValue; + style?: string; scale?: number; length?: number; spacing?: number; @@ -404,11 +406,20 @@ }); - + +
+ +
+ + + +
diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte index f37203f..7b9529e 100644 --- a/web/src/lib/components/ProjectCard.svelte +++ b/web/src/lib/components/ProjectCard.svelte @@ -1,5 +1,6 @@ -{#if projectUrl} - -
-
-

- {project.name} -

- - {formatDate(project.updatedAt)} - -
-

+

+
+

- {project.shortDescription} -

-

- -
- {#each project.tags as tag (tag.name)} - - - {#if tag.iconSvg} - - - {@html tag.iconSvg} - - {/if} - {tag.name} - - {/each} -
-
-{:else} -
-
-
-

- {project.name} -

- - {formatDate(project.updatedAt)} - -
-

- {project.shortDescription} -

-
- -
- {#each project.tags as tag (tag.name)} - - {#if tag.iconSvg} - - - {@html tag.iconSvg} - - {/if} - {tag.name} - - {/each} + {project.name} + + + {formatDate(project.updatedAt)} +
+

+ {project.shortDescription} +

-{/if} + + +
+ {#each project.tags as tag (tag.name)} + + {/each} +
+ diff --git a/web/src/lib/components/TagChip.svelte b/web/src/lib/components/TagChip.svelte new file mode 100644 index 0000000..2cc3215 --- /dev/null +++ b/web/src/lib/components/TagChip.svelte @@ -0,0 +1,28 @@ + + + + {#if iconSvg} + + + {@html iconSvg} + + {/if} + {name} + diff --git a/web/src/lib/components/admin/IconPicker.svelte b/web/src/lib/components/admin/IconPicker.svelte index 2d98db4..ccfd34e 100644 --- a/web/src/lib/components/admin/IconPicker.svelte +++ b/web/src/lib/components/admin/IconPicker.svelte @@ -204,9 +204,12 @@ {#if selectedIcon}
-
+
{#if selectedIconSvg} {@html selectedIconSvg} @@ -222,7 +225,7 @@ @@ -284,12 +287,12 @@