mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -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())
|
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
@@ -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
@@ -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%
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
<!-- 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
|
<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>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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 -->
|
<!-- 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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
<div style="view-transition-name: page-content">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user