mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 00:26:31 -06:00
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:
+38
-25
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+10
-1
@@ -24,8 +24,17 @@
|
||||
if (isDark) {
|
||||
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";
|
||||
}
|
||||
if (document.body) {
|
||||
setBodyBg();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", setBodyBg, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- 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(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
></canvas>
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
@@ -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,71 +39,30 @@
|
||||
}
|
||||
</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"
|
||||
>
|
||||
{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
|
||||
<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="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}
|
||||
</h3>
|
||||
<span
|
||||
class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
<span class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300">
|
||||
{formatDate(project.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -111,21 +73,10 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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)}
|
||||
<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>
|
||||
<TagChip name={tag.name} color={tag.color} iconSvg={tag.iconSvg} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:element>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
data-icon-container
|
||||
>
|
||||
<div class="flex size-10 items-center justify-center rounded bg-admin-bg">
|
||||
{#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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,8 +53,10 @@
|
||||
<!-- Login page has no sidebar -->
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- Admin layout with sidebar and dots shader -->
|
||||
<AppWrapper bgColor="bg-admin-bg" showThemeToggle={false}>
|
||||
<!-- 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}
|
||||
@@ -63,10 +64,9 @@
|
||||
/>
|
||||
|
||||
<!-- 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">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</AppWrapper>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user