site: owner-only dashboard link in header, mobile menu and homepage (g then d from any page); /start skips the site chrome JS
All checks were successful
Deploy production / deploy (push) Successful in 2m56s
All checks were successful
Deploy production / deploy (push) Successful in 2m56s
- Header/footer moved to #lib/components/SiteChrome.svelte, returned by a universal +layout.ts load only for non-immersive pages, so /start and the book reader never download icon libraries, dropdowns or the bits-ui Sheet. /start: 36 -> 18 JS files, 133 -> 78 KB gzip. - Header logo is a hashed asset (inlined), so it is cached with the build. - inlineStyleThreshold 4096: tiny page/component CSS is inlined into the HTML. - Umami is not loaded on /start.
This commit is contained in:
BIN
src/lib/assets/brand/favicon-32x32.png
Executable file
BIN
src/lib/assets/brand/favicon-32x32.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
13
src/lib/chrome.test.ts
Normal file
13
src/lib/chrome.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isImmersive } from './chrome';
|
||||
|
||||
describe('immersive pages', () => {
|
||||
it('skips the site chrome on /start and the book reader only', () => {
|
||||
expect(isImmersive('/start')).toBe(true);
|
||||
expect(isImmersive('/start/')).toBe(true);
|
||||
expect(isImmersive('/trackers/books/12/read')).toBe(true);
|
||||
expect(isImmersive('/')).toBe(false);
|
||||
expect(isImmersive('/starter')).toBe(false);
|
||||
expect(isImmersive('/trackers/books/12')).toBe(false);
|
||||
});
|
||||
});
|
||||
19
src/lib/chrome.ts
Normal file
19
src/lib/chrome.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Full-screen pages that render without the site header and footer: the /start new-tab
|
||||
* dashboard and the book reader. They also skip loading the header's JS (icons, menus,
|
||||
* the mobile Sheet): src/routes/+layout.ts only imports SiteChrome for other pages.
|
||||
*/
|
||||
export function isImmersive(pathname: string): boolean {
|
||||
return /^\/start\/?$/.test(pathname) || /^\/trackers\/books\/\d+\/read\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** True when a keyboard shortcut should not fire because the user is typing. */
|
||||
export function isTyping(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT'
|
||||
);
|
||||
}
|
||||
429
src/lib/components/SiteChrome.svelte
Normal file
429
src/lib/components/SiteChrome.svelte
Normal file
@@ -0,0 +1,429 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import logo from '#lib/assets/brand/favicon-32x32.png?url';
|
||||
import { isTyping } from '#lib/chrome.js';
|
||||
import { page } from '$app/state';
|
||||
import CopyButton from '#lib/components/CopyButton.svelte';
|
||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||
import { projects, visibleTrackers } from '#lib/projects.js';
|
||||
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||
import SiOsu from '~icons/simple-icons/osu';
|
||||
import SiSteam from '~icons/simple-icons/steam';
|
||||
import SiTwitch from '~icons/simple-icons/twitch';
|
||||
import SiSpotify from '~icons/simple-icons/spotify';
|
||||
import SiGithub from '~icons/simple-icons/github';
|
||||
import SiYoutube from '~icons/simple-icons/youtube';
|
||||
import SiLinkedin from '~icons/simple-icons/linkedin';
|
||||
import SiInstagram from '~icons/simple-icons/instagram';
|
||||
import SiX from '~icons/simple-icons/x';
|
||||
import SiMail from '~icons/simple-icons/gmail';
|
||||
import SiDiscord from '~icons/simple-icons/discord';
|
||||
import SiSignal from '~icons/simple-icons/signal';
|
||||
import SiWhatsapp from '~icons/simple-icons/whatsapp';
|
||||
|
||||
// Site header and footer, wrapped around every page except the full-screen ones
|
||||
// (see #lib/chrome.ts and src/routes/+layout.ts).
|
||||
type ChromeData = {
|
||||
user: { name: string } | null;
|
||||
isOwner: boolean;
|
||||
version: string;
|
||||
};
|
||||
let { data, children }: { data: ChromeData; children: Snippet } = $props();
|
||||
let menuOpen = $state(false);
|
||||
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
||||
let openMenu = $state<string | null>(null);
|
||||
type ThemePreference = 'dark' | 'system' | 'light';
|
||||
let theme = $state<ThemePreference>('dark');
|
||||
const navigation = [
|
||||
{ label: 'home', href: '/' },
|
||||
{ label: 'projects', href: '/projects' },
|
||||
{ label: 'trackers', href: '/trackers' },
|
||||
{ label: 'blog', href: '/blog' },
|
||||
{ label: 'lists', href: '/lists' }
|
||||
] as const;
|
||||
type MenuItem = { name: string; subtitle: string; href: string; external: boolean };
|
||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||
'/projects': { label: 'projects', items: projects },
|
||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
||||
});
|
||||
const socialLinks = [
|
||||
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
||||
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
|
||||
{ icon: SiLinkedin, label: 'linkedin', href: 'https://linkedin.com/in/stanrunge' },
|
||||
{ icon: SiX, label: 'x / twitter', href: 'https://x.com/stanrunge' },
|
||||
{ icon: SiYoutube, label: 'youtube', href: 'https://youtube.com/@stanrunge' },
|
||||
{ icon: SiInstagram, label: 'instagram', href: 'https://instagram.com/stan_runge' },
|
||||
{ icon: SiTwitch, label: 'twitch', href: 'https://twitch.tv/stanrunge_' },
|
||||
{
|
||||
icon: SiSpotify,
|
||||
label: 'spotify',
|
||||
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
|
||||
},
|
||||
{ icon: SiSteam, label: 'steam', href: 'https://steamcommunity.com/id/stanrunge' },
|
||||
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
|
||||
];
|
||||
const copyLinks = [
|
||||
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
|
||||
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
|
||||
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
const preference = document.documentElement.dataset.themePreference;
|
||||
theme = preference === 'light' || preference === 'system' ? preference : 'dark';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const onSystemThemeChange = () => {
|
||||
if (theme === 'system') applyTheme('system');
|
||||
};
|
||||
systemTheme.addEventListener('change', onSystemThemeChange);
|
||||
return () => systemTheme.removeEventListener('change', onSystemThemeChange);
|
||||
});
|
||||
|
||||
function applyTheme(preference: ThemePreference) {
|
||||
const resolvedTheme =
|
||||
preference === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: preference;
|
||||
document.documentElement.dataset.themePreference = preference;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', resolvedTheme === 'light' ? '#ffffff' : '#000000');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark';
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
} catch {
|
||||
// The selected theme still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
function current(href: string) {
|
||||
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
|
||||
}
|
||||
|
||||
// Owner shortcut: press g then d (outside text fields) to open the /start dashboard.
|
||||
let gPressedAt = 0;
|
||||
function handleShortcut(event: KeyboardEvent) {
|
||||
if (!data.isOwner || event.defaultPrevented || event.repeat) return;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || isTyping(event.target)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'g') {
|
||||
gPressedAt = event.timeStamp;
|
||||
} else if (key === 'd' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
|
||||
gPressedAt = 0;
|
||||
event.preventDefault();
|
||||
// Imported on use: $app/navigation is a ~47 KB chunk the public pages don't need.
|
||||
import('$app/navigation').then(({ goto }) => goto(resolve('/start')));
|
||||
} else {
|
||||
gPressedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuPointerEnter(event: PointerEvent, href: string) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
openMenu = href;
|
||||
}
|
||||
|
||||
function handleMenuPointerLeave(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
openMenu = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleShortcut} />
|
||||
|
||||
{#snippet themeIcon()}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="6" fill={theme === 'light' ? 'currentColor' : 'none'} />
|
||||
{#if theme === 'system'}
|
||||
<path d="M12 6a6 6 0 0 1 0 12Z" fill="currentColor" stroke="none" />
|
||||
{/if}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--site-bg)] text-[var(--site-text)]">
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
|
||||
>skip to content</a
|
||||
>
|
||||
<!--
|
||||
Stacking: backdrop-blur makes the header its own stacking context, so the dropdown's z-50 only
|
||||
counts inside it. Without a z-index of its own the header then sits at z-auto/0 and anything
|
||||
positioned later in <main> (maps, chart tooltips, sticky headers) paints over the dropdown.
|
||||
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
|
||||
Dialogs/sheets/popovers are portalled to <body> and still render above both.
|
||||
-->
|
||||
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="flex shrink-0 items-center gap-3 font-semibold tracking-tight"
|
||||
aria-label="Stan Runge home"
|
||||
>
|
||||
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
||||
><img src={logo} alt="" width="32" height="32" class="h-full w-full scale-[1.7]" /></span
|
||||
>
|
||||
<span>stan runge</span>
|
||||
</a>
|
||||
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
{#if menus[item.href]}
|
||||
{@const menu = menus[item.href]}
|
||||
<div
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label={`${menu.label} menu`}
|
||||
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
||||
onpointerleave={handleMenuPointerLeave}
|
||||
>
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="cursor-pointer gap-1"
|
||||
aria-label={`Browse ${menu.label}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenu === item.href}
|
||||
onclick={() => (openMenu = null)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') openMenu = null;
|
||||
}}>{item.label}<ChevronDown size={15} /></Button
|
||||
>
|
||||
{#if openMenu === item.href}
|
||||
<div
|
||||
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
aria-label={menu.label}
|
||||
>
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
||||
>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{#each menu.items as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
rel={project.external ? 'external' : undefined}
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
>
|
||||
<ProjectIcon name={project.name} class="size-4" />
|
||||
</span>
|
||||
<span class="min-w-0"
|
||||
><span class="block font-medium">{project.name}</span><span
|
||||
class="block truncate text-xs text-muted-foreground"
|
||||
>{project.subtitle}</span
|
||||
></span
|
||||
>
|
||||
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm">{item.label}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{#if data.isOwner}
|
||||
<Button
|
||||
href={resolve('/start')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
title="Dashboard (g then d)"
|
||||
aria-keyshortcuts="g d"
|
||||
><LayoutDashboard size={16} aria-hidden="true" />dashboard</Button
|
||||
>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}
|
||||
title={`Theme: ${theme}`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button href={resolve('account')} variant="outline" size="sm" class="max-w-36 truncate"
|
||||
>{data.user.name}</Button
|
||||
>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(
|
||||
`login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
|
||||
)}
|
||||
variant="outline"
|
||||
size="sm">sign in</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<Sheet.Root bind:open={menuOpen}>
|
||||
<Sheet.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' }) + ' ml-auto md:hidden'}
|
||||
aria-label="Open navigation"><Menu size={20} /></Sheet.Trigger
|
||||
>
|
||||
<Sheet.Content side="right" class="overflow-y-auto p-6">
|
||||
<Sheet.Header
|
||||
><Sheet.Title>stan runge</Sheet.Title><Sheet.Description>Navigation</Sheet.Description
|
||||
></Sheet.Header
|
||||
>
|
||||
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
||||
{#if data.isOwner}
|
||||
<Button
|
||||
href={resolve('/start')}
|
||||
variant="outline"
|
||||
class="mb-2 justify-start gap-2"
|
||||
onclick={() => (menuOpen = false)}
|
||||
><LayoutDashboard size={16} aria-hidden="true" />dashboard</Button
|
||||
>
|
||||
{/if}
|
||||
{#each navigation as item (item.href)}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{item.label}</Button
|
||||
>
|
||||
{#if menus[item.href]}
|
||||
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
||||
{#each menus[item.href].items as project (project.name)}
|
||||
<Button
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{project.name}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<Sheet.Footer
|
||||
class="mt-auto flex-row items-center justify-between border-t border-border pt-4"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button href={resolve('account')} variant="outline" onclick={() => (menuOpen = false)}
|
||||
>account</Button
|
||||
>
|
||||
<form method="POST" action="/logout">
|
||||
<Button type="submit" variant="outline">sign out</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Button href={resolve('login')} variant="outline" onclick={() => (menuOpen = false)}
|
||||
>sign in</Button
|
||||
>
|
||||
<Button
|
||||
href={resolve('register')}
|
||||
variant="outline"
|
||||
onclick={() => (menuOpen = false)}>register</Button
|
||||
>
|
||||
{/if}
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
|
||||
<footer
|
||||
class="mt-16 border-t border-[var(--site-border)]"
|
||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
|
||||
>
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
|
||||
>{data.version.slice(0, 7)}</span
|
||||
></span
|
||||
>
|
||||
<a
|
||||
href="https://git.stanrunge.dev/stan/website"
|
||||
rel="external"
|
||||
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
|
||||
>source <ExternalLink size={14} aria-hidden="true" /></a
|
||||
>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
|
||||
{#each socialLinks as { icon: Icon, label, href } (label)}
|
||||
<Button
|
||||
{href}
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
rel="external"
|
||||
aria-label={label}
|
||||
title={label}><Icon class="size-6" /></Button
|
||||
>
|
||||
{/each}
|
||||
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1100px) and (min-height: 700px) {
|
||||
.cod-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) and (min-height: 650px) {
|
||||
.tracker-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
.tracker-footer > div {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.tracker-footer nav {
|
||||
display: none;
|
||||
}
|
||||
.tracker-footer > div > div {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,153 +1,18 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve, asset } from '$app/paths';
|
||||
import { dev } from '$app/env';
|
||||
import { page } from '$app/state';
|
||||
import { getSeo } from '#lib/seo.js';
|
||||
import CopyButton from '#lib/components/CopyButton.svelte';
|
||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||
import { projects, visibleTrackers } from '#lib/projects.js';
|
||||
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import SiOsu from '~icons/simple-icons/osu';
|
||||
import SiSteam from '~icons/simple-icons/steam';
|
||||
import SiTwitch from '~icons/simple-icons/twitch';
|
||||
import SiSpotify from '~icons/simple-icons/spotify';
|
||||
import SiGithub from '~icons/simple-icons/github';
|
||||
import SiYoutube from '~icons/simple-icons/youtube';
|
||||
import SiLinkedin from '~icons/simple-icons/linkedin';
|
||||
import SiInstagram from '~icons/simple-icons/instagram';
|
||||
import SiX from '~icons/simple-icons/x';
|
||||
import SiMail from '~icons/simple-icons/gmail';
|
||||
import SiDiscord from '~icons/simple-icons/discord';
|
||||
import SiSignal from '~icons/simple-icons/signal';
|
||||
import SiWhatsapp from '~icons/simple-icons/whatsapp';
|
||||
|
||||
// The header/footer are in #lib/components/SiteChrome.svelte, which +layout.ts loads only
|
||||
// for pages that show them, so the /start new-tab page stays small.
|
||||
let { data, children } = $props();
|
||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
||||
// The book reader is a full-screen view; the site header and footer would sit on top of it.
|
||||
const immersive = $derived(
|
||||
/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname) ||
|
||||
/^\/start\/?$/.test(page.url.pathname)
|
||||
);
|
||||
let menuOpen = $state(false);
|
||||
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
||||
let openMenu = $state<string | null>(null);
|
||||
type ThemePreference = 'dark' | 'system' | 'light';
|
||||
let theme = $state<ThemePreference>('dark');
|
||||
const navigation = [
|
||||
{ label: 'home', href: '/' },
|
||||
{ label: 'projects', href: '/projects' },
|
||||
{ label: 'trackers', href: '/trackers' },
|
||||
{ label: 'blog', href: '/blog' },
|
||||
{ label: 'lists', href: '/lists' }
|
||||
] as const;
|
||||
type MenuItem = { name: string; subtitle: string; href: string; external: boolean };
|
||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||
'/projects': { label: 'projects', items: projects },
|
||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
||||
});
|
||||
const socialLinks = [
|
||||
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
||||
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
|
||||
{ icon: SiLinkedin, label: 'linkedin', href: 'https://linkedin.com/in/stanrunge' },
|
||||
{ icon: SiX, label: 'x / twitter', href: 'https://x.com/stanrunge' },
|
||||
{ icon: SiYoutube, label: 'youtube', href: 'https://youtube.com/@stanrunge' },
|
||||
{ icon: SiInstagram, label: 'instagram', href: 'https://instagram.com/stan_runge' },
|
||||
{ icon: SiTwitch, label: 'twitch', href: 'https://twitch.tv/stanrunge_' },
|
||||
{
|
||||
icon: SiSpotify,
|
||||
label: 'spotify',
|
||||
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
|
||||
},
|
||||
{ icon: SiSteam, label: 'steam', href: 'https://steamcommunity.com/id/stanrunge' },
|
||||
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
|
||||
];
|
||||
const copyLinks = [
|
||||
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
|
||||
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
|
||||
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
const preference = document.documentElement.dataset.themePreference;
|
||||
theme = preference === 'light' || preference === 'system' ? preference : 'dark';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const onSystemThemeChange = () => {
|
||||
if (theme === 'system') applyTheme('system');
|
||||
};
|
||||
systemTheme.addEventListener('change', onSystemThemeChange);
|
||||
return () => systemTheme.removeEventListener('change', onSystemThemeChange);
|
||||
});
|
||||
|
||||
function applyTheme(preference: ThemePreference) {
|
||||
const resolvedTheme =
|
||||
preference === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: preference;
|
||||
document.documentElement.dataset.themePreference = preference;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', resolvedTheme === 'light' ? '#ffffff' : '#000000');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark';
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
} catch {
|
||||
// The selected theme still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
function current(href: string) {
|
||||
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
|
||||
}
|
||||
|
||||
function handleMenuPointerEnter(event: PointerEvent, href: string) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
openMenu = href;
|
||||
}
|
||||
|
||||
function handleMenuPointerLeave(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
openMenu = null;
|
||||
}
|
||||
const Chrome = $derived(data.Chrome);
|
||||
</script>
|
||||
|
||||
{#snippet themeIcon()}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="6" fill={theme === 'light' ? 'currentColor' : 'none'} />
|
||||
{#if theme === 'system'}
|
||||
<path d="M12 6a6 6 0 0 1 0 12Z" fill="currentColor" stroke="none" />
|
||||
{/if}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head>
|
||||
{#if !dev}
|
||||
{#if !dev && !page.url.pathname.startsWith('/start')}
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.stanrunge.dev/script.js"
|
||||
@@ -176,258 +41,10 @@
|
||||
<meta name="twitter:image:alt" content={seo.imageAlt} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--site-bg)] text-[var(--site-text)]">
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
|
||||
>skip to content</a
|
||||
>
|
||||
<!--
|
||||
Stacking: backdrop-blur makes the header its own stacking context, so the dropdown's z-50 only
|
||||
counts inside it. Without a z-index of its own the header then sits at z-auto/0 and anything
|
||||
positioned later in <main> (maps, chart tooltips, sticky headers) paints over the dropdown.
|
||||
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
|
||||
Dialogs/sheets/popovers are portalled to <body> and still render above both.
|
||||
-->
|
||||
{#if !immersive}
|
||||
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="flex shrink-0 items-center gap-3 font-semibold tracking-tight"
|
||||
aria-label="Stan Runge home"
|
||||
>
|
||||
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
||||
><img
|
||||
src={asset('favicon-32x32.png')}
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
class="h-full w-full scale-[1.7]"
|
||||
/></span
|
||||
>
|
||||
<span>stan runge</span>
|
||||
</a>
|
||||
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
{#if menus[item.href]}
|
||||
{@const menu = menus[item.href]}
|
||||
<div
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label={`${menu.label} menu`}
|
||||
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
||||
onpointerleave={handleMenuPointerLeave}
|
||||
>
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="cursor-pointer gap-1"
|
||||
aria-label={`Browse ${menu.label}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenu === item.href}
|
||||
onclick={() => (openMenu = null)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') openMenu = null;
|
||||
}}>{item.label}<ChevronDown size={15} /></Button
|
||||
>
|
||||
{#if openMenu === item.href}
|
||||
<div
|
||||
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
aria-label={menu.label}
|
||||
>
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
||||
>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{#each menu.items as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
rel={project.external ? 'external' : undefined}
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
|
||||
>
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
>
|
||||
<ProjectIcon name={project.name} class="size-4" />
|
||||
</span>
|
||||
<span class="min-w-0"
|
||||
><span class="block font-medium">{project.name}</span><span
|
||||
class="block truncate text-xs text-muted-foreground"
|
||||
>{project.subtitle}</span
|
||||
></span
|
||||
>
|
||||
{#if project.external}<ExternalLink
|
||||
size={14}
|
||||
class="ml-auto shrink-0"
|
||||
/>{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
size="sm">{item.label}</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}
|
||||
title={`Theme: ${theme}`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button href={resolve('account')} variant="outline" size="sm" class="max-w-36 truncate"
|
||||
>{data.user.name}</Button
|
||||
>
|
||||
{:else}
|
||||
<Button
|
||||
href={resolve(
|
||||
`login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
|
||||
)}
|
||||
variant="outline"
|
||||
size="sm">sign in</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<Sheet.Root bind:open={menuOpen}>
|
||||
<Sheet.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' }) + ' ml-auto md:hidden'}
|
||||
aria-label="Open navigation"><Menu size={20} /></Sheet.Trigger
|
||||
>
|
||||
<Sheet.Content side="right" class="overflow-y-auto p-6">
|
||||
<Sheet.Header
|
||||
><Sheet.Title>stan runge</Sheet.Title><Sheet.Description>Navigation</Sheet.Description
|
||||
></Sheet.Header
|
||||
>
|
||||
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
<Button
|
||||
href={resolve(item.href)}
|
||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{item.label}</Button
|
||||
>
|
||||
{#if menus[item.href]}
|
||||
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
||||
{#each menus[item.href].items as project (project.name)}
|
||||
<Button
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
onclick={() => (menuOpen = false)}>{project.name}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<Sheet.Footer
|
||||
class="mt-auto flex-row items-center justify-between border-t border-border pt-4"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
|
||||
>
|
||||
{#if data.user}
|
||||
<Button
|
||||
href={resolve('account')}
|
||||
variant="outline"
|
||||
onclick={() => (menuOpen = false)}>account</Button
|
||||
>
|
||||
<form method="POST" action="/logout">
|
||||
<Button type="submit" variant="outline">sign out</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Button href={resolve('login')} variant="outline" onclick={() => (menuOpen = false)}
|
||||
>sign in</Button
|
||||
>
|
||||
<Button
|
||||
href={resolve('register')}
|
||||
variant="outline"
|
||||
onclick={() => (menuOpen = false)}>register</Button
|
||||
>
|
||||
{/if}
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
|
||||
{#if !immersive}
|
||||
<footer
|
||||
class="mt-16 border-t border-[var(--site-border)]"
|
||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
|
||||
>
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
|
||||
>{data.version.slice(0, 7)}</span
|
||||
></span
|
||||
>
|
||||
<a
|
||||
href="https://git.stanrunge.dev/stan/website"
|
||||
rel="external"
|
||||
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
|
||||
>source <ExternalLink size={14} aria-hidden="true" /></a
|
||||
>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
|
||||
{#each socialLinks as { icon: Icon, label, href } (label)}
|
||||
<Button
|
||||
{href}
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
rel="external"
|
||||
aria-label={label}
|
||||
title={label}><Icon class="size-6" /></Button
|
||||
>
|
||||
{/each}
|
||||
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1100px) and (min-height: 700px) {
|
||||
.cod-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) and (min-height: 650px) {
|
||||
.tracker-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
.tracker-footer > div {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.tracker-footer nav {
|
||||
display: none;
|
||||
}
|
||||
.tracker-footer > div > div {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{#if Chrome}
|
||||
<Chrome {data}>{@render children()}</Chrome>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col bg-[var(--site-bg)] text-[var(--site-text)]">
|
||||
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
12
src/routes/+layout.ts
Normal file
12
src/routes/+layout.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { isImmersive } from '#lib/chrome.js';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
// The site header/footer (icon libraries, dropdowns, the bits-ui Sheet) live in SiteChrome,
|
||||
// imported only for pages that show them. Immersive pages such as the /start new-tab
|
||||
// dashboard never download that code. Universal loads may return components.
|
||||
export const load: LayoutLoad = async ({ data, url }) => ({
|
||||
...data,
|
||||
Chrome: isImmersive(url.pathname)
|
||||
? null
|
||||
: (await import('#lib/components/SiteChrome.svelte')).default
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
import Mail from 'lucide-svelte/icons/mail';
|
||||
import BriefcaseBusiness from 'lucide-svelte/icons/briefcase-business';
|
||||
import GraduationCap from 'lucide-svelte/icons/graduation-cap';
|
||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -49,6 +50,22 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
{#if data.isOwner}
|
||||
<!-- Owner only: a shortcut to the private /start dashboard. Visitors never see it. -->
|
||||
<a
|
||||
href={resolve('/start')}
|
||||
class="mt-4 flex items-center gap-3 rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-4 py-3 hover:border-[var(--site-text)]"
|
||||
>
|
||||
<LayoutDashboard size={20} aria-hidden="true" />
|
||||
<span class="min-w-0"
|
||||
><span class="block font-medium">dashboard</span><span
|
||||
class="block truncate text-sm text-[var(--site-muted)]"
|
||||
>tasks, server, backups, notifications and links · g then d from any page</span
|
||||
></span
|
||||
>
|
||||
<ArrowRight size={18} class="ml-auto shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
<section
|
||||
class="grid items-center gap-8 pt-7 pb-12 sm:py-20 md:grid-cols-[minmax(0,1fr)_minmax(0,320px)] md:gap-16"
|
||||
>
|
||||
|
||||
@@ -20,6 +20,10 @@ export default defineConfig({
|
||||
// https, which is right for both staging and production from one image.
|
||||
adapter: adapter(),
|
||||
|
||||
// Inline tiny stylesheets (component/page CSS) into the HTML: one request fewer per
|
||||
// page. The big shared app.css stays a cached /_app/immutable file.
|
||||
inlineStyleThreshold: 4096,
|
||||
|
||||
csrf: {
|
||||
// Allow form posts from the dev site, which Caddy also serves over https.
|
||||
trustedOrigins: ['https://dev.stanrunge.dev']
|
||||
|
||||
Reference in New Issue
Block a user