Vellum/web/src/lib/components/Shell.svelte
mithral e55686bd21 Apply the brand: charcoal, blue-violet, purple, and the real logo
Replaces the gold experiment with the actual brand.

- Palette: brand charcoal (#303030, sampled from the logo's own background)
  with the logo's glow as the primary accent (#6d7cff) and its purple
  (#c084fc) as a secondary. Text is the palette's white / light gray. Light
  mode re-tuned to the same hues.
- Logo: the source mark keyed off its charcoal background to RGBA, cropped to
  its content bounds, and added as web/public/logo.png. It now appears in the
  sidebar and on the login screen; a square favicon is derived from it and
  wired into index.html.
- The featured kicker uses the purple secondary, so both brand hues are present
  without adding a second competing accent.

Verified: logo loads in the app, accent resolves to #6d7cff, secondary to
#c084fc, surface to #303030.
2026-10-10 17:41:49 -04:00

338 lines
8.7 KiB
Svelte

<script lang="ts">
import { fly } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { user, logout, mediaTypes, counts } from '../session'
import { view, go, type View } from '../nav'
import Home from './Home.svelte'
import Browse from './Browse.svelte'
import ItemDetail from './ItemDetail.svelte'
import Reader from './Reader.svelte'
import SearchResults from './SearchResults.svelte'
import Libraries from './Libraries.svelte'
import Users from './Users.svelte'
import Settings from './Settings.svelte'
let searchBox = $state('')
let scrolled = $state(false)
let contentEl = $state<HTMLDivElement>()
// A stable key for the current view, so navigating transitions the content
// instead of swapping it instantly.
const viewKey = $derived(JSON.stringify($view))
function onScroll() {
scrolled = (contentEl?.scrollTop ?? 0) > 8
}
// Each new view starts at the top, and the topbar un-frosts.
$effect(() => {
viewKey
if (contentEl) {
contentEl.scrollTo({ top: 0 })
scrolled = false
}
})
const types = $derived(
($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0),
)
function submitSearch(event: SubmitEvent) {
event.preventDefault()
const q = searchBox.trim()
if (q) go({ name: 'search', query: q })
}
function title(v: View): string {
switch (v.name) {
case 'home':
return 'Home'
case 'browse':
if (v.kind) return types.find((t) => t.Kind === v.kind)?.Name ?? 'Browse'
return 'Browse'
case 'item':
return ''
case 'reader':
return v.title
case 'search':
return 'Search'
case 'libraries':
return 'Libraries'
case 'users':
return 'Users'
case 'settings':
return 'Appearance'
}
}
</script>
<div class="shell">
<aside class="nav">
<button class="brand" onclick={() => go({ name: 'home' })}>
<img class="mark" src="/logo.png" alt="" />
<strong>Vellum</strong>
</button>
<nav class="links">
<button class="navlink" class:active={$view.name === 'home'} onclick={() => go({ name: 'home' })}>
<span>Home</span>
</button>
{#each types as t (t.Kind)}
<button
class="navlink"
class:active={$view.name === 'browse' && $view.kind === t.Kind}
onclick={() => go({ name: 'browse', kind: t.Kind })}
>
<span>{t.Name}</span>
<span class="count">{$counts[t.Kind]}</span>
</button>
{/each}
<button class="navlink" class:active={$view.name === 'browse' && !$view.kind} onclick={() => go({ name: 'browse' })}>
<span>Browse all</span>
</button>
<button class="navlink" class:active={$view.name === 'settings'} onclick={() => go({ name: 'settings' })}>
<span>Appearance</span>
</button>
</nav>
{#if $user?.is_admin}
<div class="section">Manage</div>
<nav class="links">
<button class="navlink" class:active={$view.name === 'libraries'} onclick={() => go({ name: 'libraries' })}>
<span>Libraries</span>
</button>
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
<span>Users</span>
</button>
</nav>
{/if}
<div class="foot">
<div class="who">
<span class="avatar">{($user?.display_name || $user?.username || '?').slice(0, 1).toUpperCase()}</span>
<span class="name">
<strong>{$user?.display_name || $user?.username}</strong>
<span class="faint">@{$user?.username}{#if $user?.is_admin} · admin{/if}</span>
</span>
</div>
<button class="btn btn-ghost" onclick={logout}>Sign out</button>
</div>
</aside>
<main class="main">
<header class="topbar" class:scrolled>
{#if title($view)}
<h1>{title($view)}</h1>
{:else}
<button class="back" onclick={() => history.back()} aria-label="Back">←</button>
{/if}
<form class="search" onsubmit={submitSearch}>
<input
class="input"
type="search"
placeholder="Search your library"
bind:value={searchBox}
aria-label="Search"
/>
</form>
</header>
<div class="content" bind:this={contentEl} onscroll={onScroll}>
{#key viewKey}
<div
class="view"
in:fly={{ y: 8, duration: 220, easing: cubicOut }}
>
{#if $view.name === 'home'}
<Home />
{:else if $view.name === 'browse'}
<Browse kind={$view.kind} libraryId={$view.libraryId} />
{:else if $view.name === 'item'}
<ItemDetail id={$view.id} />
{:else if $view.name === 'reader'}
<Reader itemId={$view.itemId} />
{:else if $view.name === 'search'}
<SearchResults query={$view.query} />
{:else if $view.name === 'libraries'}
<Libraries />
{:else if $view.name === 'users'}
<Users />
{:else if $view.name === 'settings'}
<Settings />
{/if}
</div>
{/key}
</div>
</main>
</div>
<style>
.shell {
display: grid;
grid-template-columns: var(--nav-width) 1fr;
height: 100%;
}
.nav {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border: none;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 1.071rem;
}
.mark {
height: 26px;
width: auto;
display: block;
}
.links {
display: flex;
flex-direction: column;
gap: 1px;
}
.navlink {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
font-size: .964rem;
}
.navlink:hover {
background: var(--surface-2);
color: var(--text);
}
.navlink.active {
background: var(--surface-3);
color: var(--text);
font-weight: 500;
}
.count {
font-size: .857rem;
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.section {
margin: 0 var(--space-2) -8px;
font-size: .786rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--faint);
}
.foot {
margin-top: auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.who {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-1);
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--surface-3);
font-size: .857rem;
font-weight: 600;
flex: none;
}
.who .name {
display: flex;
flex-direction: column;
line-height: 1.25;
min-width: 0;
}
.who strong {
font-size: .929rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who .faint {
font-size: .786rem;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
height: 60px;
padding: 0 var(--space-6);
flex: none;
border-bottom: 1px solid transparent;
transition:
background var(--t-base) var(--ease),
border-color var(--t-base) var(--ease);
}
/* Frosts once the content moves, so the header separates from the page
without a permanent bar of chrome. */
.topbar.scrolled {
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom-color: var(--border);
}
.search {
flex: 1;
max-width: 360px;
}
.back {
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--text);
width: 34px;
height: 34px;
border-radius: 50%;
cursor: pointer;
font-size: 1.143rem;
line-height: 1;
}
.back:hover {
background: var(--surface-3);
}
.content {
flex: 1;
overflow-y: auto;
padding: var(--space-4) var(--space-6) var(--space-7);
}
@media (max-width: 760px) {
.shell {
grid-template-columns: 1fr;
}
.nav {
display: none;
}
}
</style>