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())
}
/// 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<Response> {
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,
+54 -110
View File
@@ -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;
}
+11 -2
View File
@@ -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,
});
}
})();
</script>
%sveltekit.head%
+1 -1
View File
@@ -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,
-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 -->
<!-- POSITION: -m-1 centers the stroke ring behind the avatar -->
<div
class="absolute inset-0 -m-1 rounded-full bg-zinc-100 dark:bg-zinc-900"
style="width: 104px; height: 104px;"
class="absolute inset-0 -m-1 size-[104px] rounded-full bg-zinc-100 dark:bg-zinc-900"
></div>
<!-- Avatar circle -->
@@ -106,8 +105,7 @@
<!-- POSITION: bottom/right values place center on avatar circumference -->
<!-- For 96px avatar at 315° (bottom-right): ~4px from edge -->
<div
class="absolute size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900"
style="bottom: 2px; right: 2px;"
class="absolute bottom-0.5 right-0.5 size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900"
></div>
</div>
+19 -8
View File
@@ -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 @@
});
</script>
<canvas
bind:this={canvas}
class={cn(
"pointer-events-none fixed inset-0 -z-10 transition-opacity duration-1300 ease-out",
ready ? "opacity-100" : "opacity-0",
className,
)}
></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}
class={cn(
"absolute inset-0 z-10 transition-opacity duration-1300 ease-out",
ready ? "opacity-100" : "opacity-0",
className,
)}
></canvas>
</div>
+42 -91
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { cn } from "$lib/utils";
import TagChip from "./TagChip.svelte";
import type { AdminProject } from "$lib/admin-types";
interface Props {
@@ -12,12 +13,14 @@
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(
project.demoUrl ||
(project.githubRepo ? `https://github.com/${project.githubRepo}` : null),
);
const isLink = $derived(!!projectUrl);
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
@@ -36,96 +39,44 @@
}
</script>
{#if projectUrl}
<a
href={projectUrl}
target="_blank"
rel="noopener noreferrer"
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"
<svelte:element
this={isLink ? "a" : "div"}
href={isLink ? projectUrl : undefined}
target={isLink ? "_blank" : undefined}
rel={isLink ? "noopener noreferrer" : undefined}
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",
isLink &&
"group 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={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.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(
"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",
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"
>
{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)}
<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}
{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>
{/if}
<!-- TODO: Add link to project search with tag filtering -->
<div class="mt-auto flex flex-wrap gap-1">
{#each project.tags as tag (tag.name)}
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
{/each}
</div>
</svelte:element>
+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 -->
{#if selectedIcon}
<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">
<div
class="flex size-10 items-center justify-center rounded bg-admin-bg"
data-icon-container
>
{#if selectedIconSvg}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html selectedIconSvg}
@@ -222,7 +225,7 @@
<button
type="button"
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
</button>
@@ -284,12 +287,12 @@
<button
type="button"
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)}
title={result.identifier}
>
<!-- 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}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html cachedSvg}
@@ -338,14 +341,9 @@
<!-- Could add "star" button to favorite frequently used icons -->
<style>
/* Ensure SVG icons inherit size */
:global(.size-9 svg) {
/* Ensure dynamically-injected SVG icons fill their container */
[data-icon-container] :global(svg) {
width: 100%;
height: 100%;
}
:global(.size-10 svg) {
width: 1.5rem;
height: 1.5rem;
}
</style>
+7 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { cn } from "$lib/utils";
import Button from "./Button.svelte";
interface Props {
@@ -11,6 +12,7 @@
onconfirm?: () => void;
oncancel?: () => void;
children?: import("svelte").Snippet;
class?: string;
}
let {
@@ -23,6 +25,7 @@
onconfirm,
oncancel,
children,
class: className,
}: Props = $props();
function handleCancel() {
@@ -51,7 +54,10 @@
tabindex="-1"
>
<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"
aria-modal="true"
>
+2 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { resolve } from "$app/paths";
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { page } from "$app/stores";
const status = $derived($page.status);
@@ -21,7 +20,7 @@
<title>{status} - {message}</title>
</svelte:head>
<AppWrapper>
<main class="page-main">
<div class="flex min-h-screen items-center justify-center">
<div class="mx-4 max-w-2xl text-center">
<h1 class="mb-4 font-hanken text-8xl text-text-secondary">{status}</h1>
@@ -36,4 +35,4 @@
{/if}
</div>
</div>
</AppWrapper>
</main>
+23 -5
View File
@@ -10,6 +10,7 @@
import { page } from "$app/stores";
import { onNavigate } from "$app/navigation";
import Dots from "$lib/components/Dots.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
let { children, data } = $props();
@@ -40,6 +41,13 @@
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) => {
document.startViewTransition(async () => {
resolve();
@@ -57,6 +65,7 @@
scrollbars: {
autoHide: "leave",
autoHideDelay: 800,
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
},
});
@@ -92,12 +101,21 @@
</svelte:head>
<!-- Persistent background layer - only for public routes -->
<!-- These elements have view-transition-name to exclude them from page transitions -->
{#if showGlobalBackground}
<!-- Dots component includes both background overlay and animated dots -->
<Dots style="view-transition-name: background" />
<!-- Theme toggle - persistent across page transitions -->
<div
class="pointer-events-none fixed inset-0 -z-20 bg-white dark:bg-black transition-colors duration-300"
></div>
<Dots />
class="fixed top-5 right-6 z-50"
style="view-transition-name: theme-toggle"
>
<ThemeToggle />
</div>
{/if}
<!-- Page content - transitions handled by View Transitions API -->
{@render children()}
<!-- Page content wrapper - this is what transitions between pages -->
<div style="view-transition-name: page-content">
{@render children()}
</div>
+2 -3
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import AppWrapper from "$lib/components/AppWrapper.svelte";
import ProjectCard from "$lib/components/ProjectCard.svelte";
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
import type { PageData } from "./$types";
@@ -31,7 +30,7 @@
let discordUsername = $state("");
</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="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>
</AppWrapper>
</main>
<DiscordProfileModal bind:open={discordModalOpen} username={discordUsername} />
+15 -15
View File
@@ -3,7 +3,6 @@
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import Sidebar from "$lib/components/admin/Sidebar.svelte";
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { getAdminStats } from "$lib/api";
import type { AdminStats } from "$lib/admin-types";
@@ -54,19 +53,20 @@
<!-- Login page has no sidebar -->
{@render children()}
{:else}
<!-- Admin layout with sidebar and dots shader -->
<AppWrapper bgColor="bg-admin-bg" showThemeToggle={false}>
<Sidebar
projectCount={stats?.totalProjects ?? 0}
tagCount={stats?.totalTags ?? 0}
onlogout={handleLogout}
/>
<!-- Admin layout with sidebar -->
<div
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
></div>
<Sidebar
projectCount={stats?.totalProjects ?? 0}
tagCount={stats?.totalTags ?? 0}
onlogout={handleLogout}
/>
<!-- Main content area -->
<main class="lg:pl-64">
<div class="px-4 py-8 sm:px-6 lg:px-8">
{@render children()}
</div>
</main>
</AppWrapper>
<!-- Main content area -->
<main class="lg:pl-64 min-h-screen text-admin-text">
<div class="px-4 py-8 sm:px-6 lg:px-8">
{@render children()}
</div>
</main>
{/if}
+5 -3
View File
@@ -4,7 +4,6 @@
import { page } from "$app/stores";
import Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte";
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { authStore } from "$lib/stores/auth.svelte";
let username = $state("");
@@ -39,7 +38,10 @@
<title>Admin Login | xevion.dev</title>
</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="w-full max-w-md space-y-4">
<!-- Login Form -->
@@ -95,4 +97,4 @@
</div>
</div>
</div>
</AppWrapper>
</main>
+2 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { resolve } from "$app/paths";
import AppWrapper from "$components/AppWrapper.svelte";
let { data } = $props();
@@ -13,7 +12,7 @@
<title>{title}</title>
</svelte:head>
<AppWrapper>
<main class="page-main">
<div class="min-h-screen flex items-center justify-center">
<div class="mx-4 max-w-3xl text-center">
<h1 class="text-6xl sm:text-9xl font-hanken font-black text-zinc-200">
@@ -34,4 +33,4 @@
{/if}
</div>
</div>
</AppWrapper>
</main>
+2 -4
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import AppWrapper from "$lib/components/AppWrapper.svelte";
import { OverlayScrollbarsComponent } from "overlayscrollbars-svelte";
import "overlayscrollbars/overlayscrollbars.css";
import IconDownload from "~icons/material-symbols/download-rounded";
import IconCopy from "~icons/material-symbols/content-copy-rounded";
import IconCheck from "~icons/material-symbols/check-rounded";
@@ -56,7 +54,7 @@
/>
</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="max-w-2xl w-full">
<!-- Header -->
@@ -190,4 +188,4 @@
</div>
</div>
</div>
</AppWrapper>
</main>