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.
338 lines
8.7 KiB
Svelte
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>
|