books: immersive reader route, PDF resume fix, scalable placeholder covers, read-only books mount in prod deploy, README
This commit is contained in:
24
README.md
24
README.md
@@ -236,6 +236,30 @@ local database and migrations, and `db/reorg/README.md` for the one-time move of
|
|||||||
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
|
||||||
upsert reference data and run on every production deploy.
|
upsert reference data and run on every production deploy.
|
||||||
|
|
||||||
|
## Book tracker
|
||||||
|
|
||||||
|
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
|
||||||
|
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
|
||||||
|
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
|
||||||
|
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
|
||||||
|
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
|
||||||
|
position per file, autosaves progress and sessions, and stores highlights with notes.
|
||||||
|
|
||||||
|
Files are never public. They are streamed (with Range support) by
|
||||||
|
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
|
||||||
|
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
|
||||||
|
`hooks.server.ts`).
|
||||||
|
|
||||||
|
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
|
||||||
|
|
||||||
|
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
|
||||||
|
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
|
||||||
|
|
||||||
|
In production, `scripts/deploy-remote.sh` writes `/opt/personal-website/compose.books.yml`, which
|
||||||
|
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
|
||||||
|
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
|
||||||
|
be opened.
|
||||||
|
|
||||||
## Money tracker
|
## Money tracker
|
||||||
|
|
||||||
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
`/projects/money-tracker` is restricted to administrator accounts. Its tables
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ environment_name=production
|
|||||||
|
|
||||||
image="git.stanrunge.dev/stan/website:$sha"
|
image="git.stanrunge.dev/stan/website:$sha"
|
||||||
compose="$directory/compose.prod.yml"
|
compose="$directory/compose.prod.yml"
|
||||||
|
compose_files=(-f "$compose")
|
||||||
|
# Book files (ebooks + covers) live on the host and are bind-mounted read-only at /books;
|
||||||
|
# the app streams them only to the owner via /trackers/books/files/<id> (no static path).
|
||||||
|
# Layout: $books_host_dir/library/<files.path> and $books_host_dir/covers/<works.cover_path>.
|
||||||
|
# compose.prod.yml is host-managed, so the mount goes in a generated override file.
|
||||||
|
books_host_dir=${BOOKS_HOST_DIR:-/home/stan/books}
|
||||||
|
case "$books_host_dir" in /*) ;; *) echo 'BOOKS_HOST_DIR must be absolute' >&2; exit 2 ;; esac
|
||||||
|
if [ -d "$books_host_dir/library" ]; then
|
||||||
|
sudo tee "$directory/compose.books.yml" >/dev/null <<YAML
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
BOOKS_ROOT: /books
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: $books_host_dir
|
||||||
|
target: /books
|
||||||
|
read_only: true
|
||||||
|
YAML
|
||||||
|
compose_files+=(-f "$directory/compose.books.yml")
|
||||||
|
else
|
||||||
|
echo "No $books_host_dir/library on this host; book files will not be readable" >&2
|
||||||
|
fi
|
||||||
# Images built before the repo rename (stan/personal-website -> stan/website) keep
|
# Images built before the repo rename (stan/personal-website -> stan/website) keep
|
||||||
# their old local name; retag so rollbacks to those SHAs still work.
|
# their old local name; retag so rollbacks to those SHAs still work.
|
||||||
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
|
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||||
@@ -34,7 +57,7 @@ rollback() {
|
|||||||
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
|
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
|
||||||
echo "Health check failed; restoring prior image tag $previous_tag" >&2
|
echo "Health check failed; restoring prior image tag $previous_tag" >&2
|
||||||
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
|
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
|
||||||
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
|
sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap rollback ERR
|
trap rollback ERR
|
||||||
@@ -92,9 +115,9 @@ sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
|||||||
|
|
||||||
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
|
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
|
||||||
switched=1
|
switched=1
|
||||||
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
|
sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
|
||||||
|
|
||||||
container=$(sudo docker compose --env-file "$directory/.env" -f "$compose" ps -q app)
|
container=$(sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" ps -q app)
|
||||||
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
||||||
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
|
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
|
||||||
if [ "$actual_image" != "$expected_image" ]; then
|
if [ "$actual_image" != "$expected_image" ]; then
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
container-type: inline-size;
|
||||||
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
|
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
|
||||||
background: var(--site-hover);
|
background: var(--site-hover);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -99,31 +100,26 @@
|
|||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
font-size: 1rem;
|
/* Scale with the cover's width so thumbnails don't break words mid-way. */
|
||||||
|
font-size: clamp(0.55rem, 12cqi, 1.6rem);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 6;
|
-webkit-line-clamp: 6;
|
||||||
line-clamp: 6;
|
line-clamp: 6;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
.g-author {
|
.g-author {
|
||||||
font-size: 0.7rem;
|
font-size: clamp(0.5rem, 6.5cqi, 0.85rem);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.cover-sm .g-title {
|
@container (max-width: 90px) {
|
||||||
font-size: 0.6rem;
|
.g-author {
|
||||||
}
|
display: none;
|
||||||
.cover-sm .g-author {
|
}
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.cover-lg .g-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.cover-lg .g-author {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
.is-placeholder {
|
.is-placeholder {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|||||||
@@ -127,6 +127,9 @@
|
|||||||
let lastScale = 0;
|
let lastScale = 0;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const s = scale;
|
const s = scale;
|
||||||
|
// The first scale with pages loaded is the baseline; the initial jump to the saved
|
||||||
|
// position happens in onMount and must not be undone by a "keep position" here.
|
||||||
|
if (!pages.length || !width) return;
|
||||||
if (!lastScale) {
|
if (!lastScale) {
|
||||||
lastScale = s;
|
lastScale = s;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
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));
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
||||||
let openMenu = $state<string | null>(null);
|
let openMenu = $state<string | null>(null);
|
||||||
@@ -184,215 +186,224 @@
|
|||||||
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
|
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.
|
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">
|
{#if !immersive}
|
||||||
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
|
||||||
<a
|
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||||
href={resolve('/')}
|
<a
|
||||||
class="flex shrink-0 items-center gap-3 font-semibold tracking-tight"
|
href={resolve('/')}
|
||||||
aria-label="Stan Runge home"
|
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>
|
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
|
||||||
</a>
|
><img
|
||||||
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
src={asset('favicon-32x32.png')}
|
||||||
{#each navigation as item (item.href)}
|
alt=""
|
||||||
{#if menus[item.href]}
|
width="32"
|
||||||
{@const menu = menus[item.href]}
|
height="32"
|
||||||
<div
|
class="h-full w-full scale-[1.7]"
|
||||||
class="relative"
|
/></span
|
||||||
role="group"
|
>
|
||||||
aria-label={`${menu.label} menu`}
|
<span>stan runge</span>
|
||||||
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
</a>
|
||||||
onpointerleave={handleMenuPointerLeave}
|
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||||
>
|
{#each navigation as item (item.href)}
|
||||||
<Button
|
{#if menus[item.href]}
|
||||||
href={resolve(item.href)}
|
{@const menu = menus[item.href]}
|
||||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
<div
|
||||||
size="sm"
|
class="relative"
|
||||||
class="cursor-pointer gap-1"
|
role="group"
|
||||||
aria-label={`Browse ${menu.label}`}
|
aria-label={`${menu.label} menu`}
|
||||||
aria-haspopup="true"
|
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
|
||||||
aria-expanded={openMenu === item.href}
|
onpointerleave={handleMenuPointerLeave}
|
||||||
onclick={() => (openMenu = null)}
|
|
||||||
onkeydown={(event) => {
|
|
||||||
if (event.key === 'Escape') openMenu = null;
|
|
||||||
}}>{item.label}<ChevronDown size={15} /></Button
|
|
||||||
>
|
>
|
||||||
{#if openMenu === item.href}
|
<Button
|
||||||
<div
|
href={resolve(item.href)}
|
||||||
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"
|
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||||
aria-label={menu.label}
|
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
|
||||||
>
|
>
|
||||||
<a
|
{#if openMenu === item.href}
|
||||||
href={resolve(item.href)}
|
<div
|
||||||
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"
|
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"
|
||||||
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
aria-label={menu.label}
|
||||||
>
|
>
|
||||||
<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
|
<a
|
||||||
href={project.external ? project.href : resolve(project.href)}
|
href={resolve(item.href)}
|
||||||
rel={project.external ? 'external' : undefined}
|
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"
|
||||||
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"
|
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
|
||||||
>
|
>
|
||||||
<span
|
<div class="my-1 h-px bg-border"></div>
|
||||||
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
{#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"
|
||||||
>
|
>
|
||||||
<ProjectIcon name={project.name} class="size-4" />
|
<span
|
||||||
</span>
|
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||||
<span class="min-w-0"
|
>
|
||||||
><span class="block font-medium">{project.name}</span><span
|
<ProjectIcon name={project.name} class="size-4" />
|
||||||
class="block truncate text-xs text-muted-foreground"
|
</span>
|
||||||
>{project.subtitle}</span
|
<span class="min-w-0"
|
||||||
></span
|
><span class="block font-medium">{project.name}</span><span
|
||||||
>
|
class="block truncate text-xs text-muted-foreground"
|
||||||
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
|
>{project.subtitle}</span
|
||||||
</a>
|
></span
|
||||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
>
|
||||||
{/each}
|
{#if project.external}<ExternalLink
|
||||||
</div>
|
size={14}
|
||||||
{/if}
|
class="ml-auto shrink-0"
|
||||||
</div>
|
/>{/if}
|
||||||
{:else}
|
</a>
|
||||||
<Button
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
href={resolve(item.href)}
|
{/each}
|
||||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
</div>
|
||||||
size="sm">{item.label}</Button
|
{/if}
|
||||||
>
|
</div>
|
||||||
{/if}
|
{:else}
|
||||||
{/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
|
<Button
|
||||||
href={resolve(item.href)}
|
href={resolve(item.href)}
|
||||||
variant={current(item.href) ? 'secondary' : 'ghost'}
|
variant={current(item.href) ? 'secondary' : 'ghost'}
|
||||||
class="justify-start"
|
size="sm">{item.label}</Button
|
||||||
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}
|
{/if}
|
||||||
</Sheet.Footer>
|
{/each}
|
||||||
</Sheet.Content>
|
</nav>
|
||||||
</Sheet.Root>
|
<div class="hidden items-center gap-1 md:flex">
|
||||||
</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
|
<Button
|
||||||
{href}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-lg"
|
size="icon"
|
||||||
rel="external"
|
onclick={toggleTheme}
|
||||||
aria-label={label}
|
aria-label={`Theme: ${theme}. Switch mode`}
|
||||||
title={label}><Icon class="size-6" /></Button
|
title={`Theme: ${theme}`}>{@render themeIcon()}</Button
|
||||||
>
|
>
|
||||||
{/each}
|
{#if data.user}
|
||||||
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
|
<Button href={resolve('account')} variant="outline" size="sm" class="max-w-36 truncate"
|
||||||
</nav>
|
>{data.user.name}</Button
|
||||||
</div>
|
>
|
||||||
</footer>
|
{: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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user