684 lines
19 KiB
Svelte
684 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import { resolve } from '$app/paths';
|
|
import BookCover from '#lib/books/BookCover.svelte';
|
|
import Stars from '#lib/books/Stars.svelte';
|
|
import {
|
|
highlightCss,
|
|
readingStatuses,
|
|
statusLabels,
|
|
type HighlightColor
|
|
} from '#lib/books/shared.js';
|
|
import { Button } from '#lib/components/ui/button/index.js';
|
|
import { Input } from '#lib/components/ui/input/index.js';
|
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
|
import BookOpen from 'lucide-svelte/icons/book-open';
|
|
import Highlighter from 'lucide-svelte/icons/highlighter';
|
|
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
|
|
import History from 'lucide-svelte/icons/history';
|
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
|
import Pencil from 'lucide-svelte/icons/pencil';
|
|
import FileQuestion from 'lucide-svelte/icons/file-question';
|
|
import Lock from 'lucide-svelte/icons/lock';
|
|
import type { SubmitFunction } from '@sveltejs/kit';
|
|
import type { PageProps } from './$types';
|
|
|
|
let { data, form }: PageProps = $props();
|
|
|
|
const book = $derived(data.book);
|
|
const reading = $derived(data.reading);
|
|
let descriptionOpen = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let showSessionForm = $state(false);
|
|
|
|
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
timeZone: 'Europe/Amsterdam'
|
|
});
|
|
const timeFormat = new Intl.DateTimeFormat('en-GB', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZone: 'Europe/Amsterdam'
|
|
});
|
|
const localInput = (d: Date) =>
|
|
new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: 'Europe/Amsterdam',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
.format(d)
|
|
.replace(' ', 'T');
|
|
const size = (bytes: number) =>
|
|
bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.ceil(bytes / 1e3)} kB`;
|
|
const minutes = (a: string, b: string | null) =>
|
|
b ? Math.max(1, Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000)) : null;
|
|
|
|
const readable = $derived(data.owner ? data.files.find((f) => f.readable) : undefined);
|
|
const highlights = $derived(data.owner ? data.notes.filter((n) => n.highlight) : []);
|
|
const freeNotes = $derived(data.owner ? data.notes.filter((n) => !n.highlight) : []);
|
|
const readHref = $derived(resolve('/trackers/books/[id=integer]/read', { id: String(book.id) }));
|
|
const meta = $derived(
|
|
[
|
|
['published', book.year],
|
|
['publisher', book.publisher],
|
|
['pages', book.pageCount],
|
|
['language', book.language?.toUpperCase()],
|
|
['ISBN', book.isbn13 ?? book.isbn10]
|
|
].filter(([, v]) => v != null && v !== '') as [string, string | number][]
|
|
);
|
|
|
|
const keep: SubmitFunction =
|
|
() =>
|
|
async ({ update }) => {
|
|
await update({ reset: false });
|
|
};
|
|
const done: SubmitFunction =
|
|
() =>
|
|
async ({ result, update }) => {
|
|
await update();
|
|
if (result.type === 'success') {
|
|
editing = null;
|
|
showSessionForm = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
|
<a
|
|
href={resolve('/trackers/books')}
|
|
class="mb-8 inline-flex items-center gap-1.5 text-sm text-[var(--site-muted)] hover:text-[var(--site-text)]"
|
|
><ArrowLeft size={14} /> all books</a
|
|
>
|
|
|
|
<div class="layout">
|
|
<aside class="side">
|
|
<div class="cover-box">
|
|
<BookCover
|
|
src={book.cover}
|
|
title={book.title}
|
|
authors={book.authors}
|
|
size="lg"
|
|
placeholder={data.owner && !data.files.length}
|
|
/>
|
|
</div>
|
|
|
|
{#if data.owner}
|
|
{#if readable}
|
|
<Button href={readHref} class="mt-5 w-full gap-2" size="lg"
|
|
><BookOpen size={16} />
|
|
{reading.percent > 0 && reading.status !== 'finished'
|
|
? 'continue reading'
|
|
: 'read'}</Button
|
|
>
|
|
{:else if data.files.length}
|
|
<p class="mt-5 text-center text-sm text-[var(--site-muted)]">
|
|
{data.files.map((f) => f.format.toUpperCase()).join(', ')} can't be opened in the browser
|
|
reader yet.
|
|
</p>
|
|
{:else}
|
|
<p class="placeholder-note">
|
|
<FileQuestion size={16} /> No file yet. This is a placeholder until a copy lands in the library.
|
|
</p>
|
|
{/if}
|
|
|
|
<form method="POST" action="?/status" use:enhance={keep} class="status-group">
|
|
{#each readingStatuses as s (s)}
|
|
<button
|
|
name="status"
|
|
value={s}
|
|
class={['seg', reading.status === s && 'active']}
|
|
aria-pressed={reading.status === s}>{statusLabels[s]}</button
|
|
>
|
|
{/each}
|
|
</form>
|
|
<form method="POST" action="?/rating" use:enhance={keep} class="mt-3 flex justify-center">
|
|
<Stars value={reading.rating} size={22} name="rating" />
|
|
</form>
|
|
{:else}
|
|
<div class="mt-5 flex flex-col items-center gap-2">
|
|
{#if reading.status}<span class={['chip', `s-${reading.status}`]}
|
|
>{statusLabels[reading.status]}</span
|
|
>{/if}
|
|
<Stars value={reading.rating} size={18} />
|
|
</div>
|
|
{/if}
|
|
</aside>
|
|
|
|
<div class="min-w-0">
|
|
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl">{book.title}</h1>
|
|
{#if book.subtitle}<p class="mt-2 text-lg text-[var(--site-muted)]">{book.subtitle}</p>{/if}
|
|
{#if book.authors.length}<p class="mt-3 text-lg">{book.authors.join(', ')}</p>{/if}
|
|
|
|
{#if meta.length}
|
|
<dl class="meta">
|
|
{#each meta as [label, value] (label)}
|
|
<div>
|
|
<dt>{label}</dt>
|
|
<dd>{value}</dd>
|
|
</div>
|
|
{/each}
|
|
</dl>
|
|
{/if}
|
|
|
|
{#if book.tags.length}
|
|
<ul class="mt-5 flex flex-wrap gap-1.5">
|
|
{#each book.tags.slice(0, 16) as tag (tag)}<li class="tag">{tag}</li>{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if book.description}
|
|
<div class="mt-6">
|
|
<p class={['description', !descriptionOpen && 'clamped']}>{book.description}</p>
|
|
{#if book.description.length > 400}
|
|
<button
|
|
type="button"
|
|
class="mt-1 text-sm text-[var(--site-muted)] underline-offset-2 hover:underline"
|
|
onclick={() => (descriptionOpen = !descriptionOpen)}
|
|
>{descriptionOpen ? 'less' : 'more'}</button
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<section class="panel mt-8">
|
|
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
|
<h2 class="text-lg font-semibold tracking-tight">progress</h2>
|
|
<p class="text-sm text-[var(--site-muted)]">
|
|
{#if reading.startedAt}started {dateFormat.format(new Date(reading.startedAt))}{/if}
|
|
{#if reading.finishedAt}· finished {dateFormat.format(
|
|
new Date(reading.finishedAt)
|
|
)}{/if}
|
|
{#if reading.lastReadAt && reading.status === 'reading'}· last read {timeFormat.format(
|
|
new Date(reading.lastReadAt)
|
|
)}{/if}
|
|
</p>
|
|
</div>
|
|
<div class="mt-3 flex items-center gap-3">
|
|
<span class="bar grow"><span style:width={`${reading.percent}%`}></span></span>
|
|
<span class="text-sm font-medium tabular-nums">{Math.round(reading.percent)}%</span>
|
|
</div>
|
|
{#if reading.page && book.pageCount}
|
|
<p class="mt-1 text-xs text-[var(--site-muted)]">
|
|
page {reading.page} of {book.pageCount}
|
|
</p>
|
|
{/if}
|
|
{#if data.owner}
|
|
<form
|
|
method="POST"
|
|
action="?/progress"
|
|
use:enhance={done}
|
|
class="mt-4 flex flex-wrap items-end gap-2"
|
|
>
|
|
<label class="field"
|
|
><span>percent</span><Input
|
|
name="percent"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.1"
|
|
class="w-24"
|
|
value={reading.percent ? Math.round(reading.percent * 10) / 10 : ''}
|
|
/></label
|
|
>
|
|
{#if book.pageCount}
|
|
<label class="field"
|
|
><span>or page</span><Input
|
|
name="page"
|
|
type="number"
|
|
min="0"
|
|
max={book.pageCount}
|
|
class="w-24"
|
|
value={reading.page ?? ''}
|
|
/></label
|
|
>
|
|
<input type="hidden" name="pageCount" value={book.pageCount} />
|
|
{/if}
|
|
<Button type="submit" variant="outline" size="sm">update</Button>
|
|
</form>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if data.owner}
|
|
{#if form?.message}<p class="mt-4 text-sm text-red-500" role="alert">{form.message}</p>{/if}
|
|
|
|
<section class="mt-10">
|
|
<h2 class="section-title"><NotebookPen size={18} /> notes</h2>
|
|
<form method="POST" action="?/addNote" use:enhance={done} class="mb-4 space-y-2">
|
|
<Textarea name="note" rows={3} placeholder="Thoughts, quotes, a summary…" required />
|
|
<div class="flex items-center gap-2">
|
|
<Input name="page" type="number" min="0" placeholder="page" class="w-24" />
|
|
<Button type="submit" size="sm">add note</Button>
|
|
</div>
|
|
</form>
|
|
{#if freeNotes.length}
|
|
<ul class="space-y-3">
|
|
{#each freeNotes as n (n.id)}
|
|
<li class="note">
|
|
{#if editing === n.id}
|
|
<form method="POST" action="?/editNote" use:enhance={done} class="space-y-2">
|
|
<input type="hidden" name="id" value={n.id} />
|
|
<Textarea name="note" rows={4} value={n.note ?? ''} />
|
|
<div class="flex gap-2">
|
|
<Button type="submit" size="sm">save</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
onclick={() => (editing = null)}>cancel</Button
|
|
>
|
|
</div>
|
|
</form>
|
|
{:else}
|
|
<p class="whitespace-pre-wrap">{n.note}</p>
|
|
<footer>
|
|
<span
|
|
>{dateFormat.format(new Date(n.createdAt))}{n.page
|
|
? ` · p. ${n.page}`
|
|
: ''}</span
|
|
>
|
|
<span class="flex gap-1">
|
|
<button
|
|
type="button"
|
|
class="icon"
|
|
aria-label="Edit note"
|
|
onclick={() => (editing = n.id)}><Pencil size={13} /></button
|
|
>
|
|
<form method="POST" action="?/deleteNote" use:enhance={done}>
|
|
<input type="hidden" name="id" value={n.id} />
|
|
<button class="icon" aria-label="Delete note"><Trash2 size={13} /></button
|
|
>
|
|
</form>
|
|
</span>
|
|
</footer>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<p class="text-sm text-[var(--site-muted)]">No notes yet.</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="mt-10">
|
|
<h2 class="section-title"><Highlighter size={18} /> highlights</h2>
|
|
{#if highlights.length}
|
|
<ul class="space-y-3">
|
|
{#each highlights as h (h.id)}
|
|
<li
|
|
class="highlight"
|
|
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
|
|
highlightCss.yellow}
|
|
>
|
|
<blockquote>{h.highlight}</blockquote>
|
|
{#if editing === h.id}
|
|
<form
|
|
method="POST"
|
|
action="?/editNote"
|
|
use:enhance={done}
|
|
class="mt-2 space-y-2"
|
|
>
|
|
<input type="hidden" name="id" value={h.id} />
|
|
<Textarea
|
|
name="note"
|
|
rows={3}
|
|
value={h.note ?? ''}
|
|
placeholder="Add a note"
|
|
/>
|
|
<div class="flex gap-2">
|
|
<Button type="submit" size="sm">save</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
onclick={() => (editing = null)}>cancel</Button
|
|
>
|
|
</div>
|
|
</form>
|
|
{:else if h.note}
|
|
<p class="mt-2 text-sm whitespace-pre-wrap">{h.note}</p>
|
|
{/if}
|
|
<footer>
|
|
<span>
|
|
{dateFormat.format(new Date(h.createdAt))}{h.page ? ` · p. ${h.page}` : ''}
|
|
{#if readable && h.locator}
|
|
· <a
|
|
class="underline-offset-2 hover:underline"
|
|
href={`${readHref}?at=${encodeURIComponent(h.locator)}`}>open in reader</a
|
|
>
|
|
{/if}
|
|
</span>
|
|
<span class="flex gap-1">
|
|
<button
|
|
type="button"
|
|
class="icon"
|
|
aria-label="Edit note"
|
|
onclick={() => (editing = h.id)}><Pencil size={13} /></button
|
|
>
|
|
<form method="POST" action="?/deleteNote" use:enhance={done}>
|
|
<input type="hidden" name="id" value={h.id} />
|
|
<button class="icon" aria-label="Delete highlight"
|
|
><Trash2 size={13} /></button
|
|
>
|
|
</form>
|
|
</span>
|
|
</footer>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<p class="text-sm text-[var(--site-muted)]">
|
|
Select text in the reader to highlight it{readable ? '' : ' (once there is a file)'}.
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="mt-10">
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<h2 class="section-title mb-0"><History size={18} /> reading history</h2>
|
|
<Button size="sm" variant="outline" onclick={() => (showSessionForm = !showSessionForm)}
|
|
>{showSessionForm ? 'cancel' : 'log a session'}</Button
|
|
>
|
|
</div>
|
|
{#if showSessionForm}
|
|
<form
|
|
method="POST"
|
|
action="?/logSession"
|
|
use:enhance={done}
|
|
class="panel mb-4 grid gap-3 sm:grid-cols-2"
|
|
>
|
|
<label class="field"
|
|
><span>start</span><Input
|
|
name="startedAt"
|
|
type="datetime-local"
|
|
required
|
|
value={localInput(new Date(Date.now() - 30 * 60000))}
|
|
/></label
|
|
>
|
|
<label class="field"
|
|
><span>end</span><Input
|
|
name="endedAt"
|
|
type="datetime-local"
|
|
value={localInput(new Date())}
|
|
/></label
|
|
>
|
|
<label class="field"
|
|
><span>from page</span><Input
|
|
name="startPage"
|
|
type="number"
|
|
min="0"
|
|
value={reading.page ?? ''}
|
|
/></label
|
|
>
|
|
<label class="field"
|
|
><span>to page</span><Input name="endPage" type="number" min="0" /></label
|
|
>
|
|
<label class="field sm:col-span-2"
|
|
><span>note</span><Input name="note" placeholder="optional" /></label
|
|
>
|
|
<div class="sm:col-span-2"><Button type="submit" size="sm">save session</Button></div>
|
|
</form>
|
|
{/if}
|
|
{#if data.sessions.length}
|
|
<ul class="sessions">
|
|
{#each data.sessions as s (s.id)}
|
|
<li>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block">{timeFormat.format(new Date(s.startedAt))}</span>
|
|
<span class="block text-xs text-[var(--site-muted)]">
|
|
{#if minutes(s.startedAt, s.endedAt)}{minutes(s.startedAt, s.endedAt)} min{/if}
|
|
{#if s.startPercent != null && s.endPercent != null}
|
|
· {Math.round(s.startPercent)}% → {Math.round(s.endPercent)}%{/if}
|
|
{#if s.startPage != null && s.endPage != null}
|
|
· p. {s.startPage} → {s.endPage}{/if}
|
|
{#if s.source === 'manual'}· logged by hand{/if}
|
|
{#if s.note}· {s.note}{/if}
|
|
</span>
|
|
</span>
|
|
{#if s.pagesRead}<span class="pages">{s.pagesRead} p.</span>{/if}
|
|
<form method="POST" action="?/deleteSession" use:enhance={done}>
|
|
<input type="hidden" name="id" value={s.id} />
|
|
<button class="icon" aria-label="Delete session"><Trash2 size={13} /></button>
|
|
</form>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<p class="text-sm text-[var(--site-muted)]">
|
|
Sessions are logged automatically while you read.
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if data.files.length}
|
|
<section class="mt-10">
|
|
<h2 class="section-title"><Lock size={16} /> files</h2>
|
|
<ul class="text-sm text-[var(--site-muted)]">
|
|
{#each data.files as f (f.id)}
|
|
<li class="flex justify-between gap-4 py-1">
|
|
<span class="truncate">{f.name}</span>
|
|
<span class="shrink-0 tabular-nums"
|
|
>{f.format.toUpperCase()} · {size(f.sizeBytes)}</span
|
|
>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.layout {
|
|
display: grid;
|
|
gap: 2.5rem;
|
|
}
|
|
@media (min-width: 768px) {
|
|
.layout {
|
|
grid-template-columns: 16rem minmax(0, 1fr);
|
|
gap: 3.5rem;
|
|
}
|
|
.side {
|
|
position: sticky;
|
|
top: 6rem;
|
|
align-self: start;
|
|
}
|
|
}
|
|
.cover-box {
|
|
max-width: 14rem;
|
|
margin: 0 auto;
|
|
}
|
|
@media (min-width: 768px) {
|
|
.cover-box {
|
|
max-width: none;
|
|
}
|
|
}
|
|
.placeholder-note {
|
|
margin-top: 1.25rem;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: flex-start;
|
|
font-size: 0.85rem;
|
|
color: var(--site-muted);
|
|
border: 1px dashed var(--site-border);
|
|
border-radius: 0.5rem;
|
|
padding: 0.6rem 0.75rem;
|
|
}
|
|
.status-group {
|
|
margin-top: 1rem;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.35rem;
|
|
}
|
|
.seg {
|
|
font-size: 0.8rem;
|
|
padding: 0.4rem 0.5rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--site-border);
|
|
color: var(--site-muted);
|
|
transition:
|
|
background 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.seg:hover {
|
|
background: var(--site-hover);
|
|
color: var(--site-text);
|
|
}
|
|
.seg.active {
|
|
background: var(--site-text);
|
|
color: var(--site-bg);
|
|
border-color: var(--site-text);
|
|
}
|
|
.meta {
|
|
margin-top: 1.5rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1rem 2rem;
|
|
}
|
|
.meta dt {
|
|
font-size: 0.75rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.meta dd {
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.tag {
|
|
font-size: 0.75rem;
|
|
padding: 0.15rem 0.6rem;
|
|
border-radius: 999px;
|
|
background: var(--site-hover);
|
|
color: var(--site-muted);
|
|
}
|
|
.description {
|
|
line-height: 1.7;
|
|
white-space: pre-line;
|
|
}
|
|
.description.clamped {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 6;
|
|
line-clamp: 6;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.panel {
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.75rem;
|
|
background: var(--site-surface);
|
|
padding: 1rem 1.25rem;
|
|
}
|
|
.bar {
|
|
display: block;
|
|
height: 0.5rem;
|
|
border-radius: 999px;
|
|
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
|
|
overflow: hidden;
|
|
}
|
|
.bar > span {
|
|
display: block;
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
background: var(--site-text);
|
|
transition: width 0.4s ease;
|
|
}
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.75rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
letter-spacing: -0.01em;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.note,
|
|
.highlight {
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.75rem;
|
|
padding: 0.85rem 1rem;
|
|
background: var(--site-surface);
|
|
}
|
|
.highlight blockquote {
|
|
border-left: 3px solid var(--hl);
|
|
padding-left: 0.85rem;
|
|
font-family: Georgia, 'Times New Roman', serif;
|
|
line-height: 1.6;
|
|
background: linear-gradient(
|
|
90deg,
|
|
color-mix(in srgb, var(--hl) 12%, transparent),
|
|
transparent 70%
|
|
);
|
|
}
|
|
.note footer,
|
|
.highlight footer {
|
|
margin-top: 0.6rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 0.75rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.icon {
|
|
display: inline-flex;
|
|
border-radius: 0.375rem;
|
|
padding: 0.3rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.icon:hover {
|
|
background: var(--site-hover);
|
|
color: var(--site-text);
|
|
}
|
|
.sessions {
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.75rem;
|
|
overflow: hidden;
|
|
}
|
|
.sessions li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.55rem 0.9rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.sessions li + li {
|
|
border-top: 1px solid var(--site-border);
|
|
}
|
|
.pages {
|
|
font-size: 0.8rem;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--site-muted);
|
|
}
|
|
.chip {
|
|
display: inline-block;
|
|
font-size: 0.8rem;
|
|
padding: 0.15rem 0.7rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--site-border);
|
|
color: var(--site-muted);
|
|
}
|
|
.chip.s-reading {
|
|
color: #2563eb;
|
|
}
|
|
.chip.s-finished {
|
|
color: #16a34a;
|
|
}
|
|
.chip.s-abandoned {
|
|
color: #b45309;
|
|
}
|
|
</style>
|