timeline card ui & sorting & view change

This commit is contained in:
haerikimmm
2026-06-02 13:58:53 +09:00
parent 0840cbac9d
commit 04b5734b48
4 changed files with 817 additions and 115 deletions

View File

@@ -1,89 +1,14 @@
<script>
import svelteLogo from './assets/svelte.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import Counter from './lib/Counter.svelte'
import TimelineView from './lib/TimelineView.svelte';
</script>
<section id="center">
<div class="hero">
<img src={heroImg} class="base" width="170" height="179" alt="" />
<img src={svelteLogo} class="framework" alt="Svelte logo" />
<img src={viteLogo} class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.svelte</code> and save to test <code>HMR</code></p>
</div>
<Counter />
</section>
<main>
<TimelineView />
</main>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank" rel="noreferrer">
<img class="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://svelte.dev/" target="_blank" rel="noreferrer">
<img class="button-icon" src={svelteLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank" rel="noreferrer">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank" rel="noreferrer">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank" rel="noreferrer">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank" rel="noreferrer">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
<style>
main {
min-height: 100svh;
background: var(--bg);
}
</style>

689
src/lib/TimelineView.svelte Normal file
View File

@@ -0,0 +1,689 @@
<script>
import { journals } from './stores/journalStore.js';
/** @type {'date-desc'|'date-asc'|'country-asc'|'country-desc'} */
let sortKey = 'date-desc';
/** @type {'vertical'|'horizontal'} */
let layout = 'vertical';
// per-entry current photo index
/** @type {Record<string, number>} */
let photoIdx = {};
const sortOptions = [
{ value: 'date-desc', label: 'Newest First' },
{ value: 'date-asc', label: 'Oldest First' },
{ value: 'country-asc', label: 'Country A → Z' },
{ value: 'country-desc', label: 'Country Z → A' },
];
/** @param {import('./stores/journalStore.js').JournalEntry[]} entries */
function sorted(entries) {
return [...entries].sort((a, b) => {
switch (sortKey) {
case 'date-asc': return a.date.localeCompare(b.date);
case 'date-desc': return b.date.localeCompare(a.date);
case 'country-asc':
return a.location.country.localeCompare(b.location.country) || b.date.localeCompare(a.date);
case 'country-desc':
return b.location.country.localeCompare(a.location.country) || b.date.localeCompare(a.date);
default: return 0;
}
});
}
/** @param {string} iso */
function formatDate(iso) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
}
/**
* @param {string} id
* @param {number} total
* @param {1|-1} dir
*/
function stepPhoto(id, total, dir) {
const cur = photoIdx[id] ?? 0;
photoIdx = { ...photoIdx, [id]: (cur + dir + total) % total };
}
/**
* @param {string} id
* @param {number} i
*/
function setPhoto(id, i) {
photoIdx = { ...photoIdx, [id]: i };
}
$: sortedEntries = sorted($journals);
</script>
<section class="timeline-view">
<!-- ── Toolbar ── -->
<header class="toolbar">
<div class="title-block">
<p class="eyebrow">Travel Journal</p>
<h1 class="page-title">My Journey</h1>
</div>
<div class="controls">
<!-- Sort -->
<div class="sort-control">
<label for="sort-select">Sort</label>
<select id="sort-select" bind:value={sortKey}>
{#each sortOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Layout toggle -->
<div class="layout-toggle" role="group" aria-label="Timeline layout">
<button
class="toggle-btn"
class:active={layout === 'vertical'}
on:click={() => (layout = 'vertical')}
aria-pressed={layout === 'vertical'}
title="Vertical timeline"
>
<!-- vertical bars icon -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="1" width="14" height="2" rx="1" fill="currentColor"/>
<rect x="1" y="5" width="14" height="2" rx="1" fill="currentColor"/>
<rect x="1" y="9" width="14" height="2" rx="1" fill="currentColor"/>
<rect x="1" y="13" width="14" height="2" rx="1" fill="currentColor"/>
</svg>
Vertical
</button>
<button
class="toggle-btn"
class:active={layout === 'horizontal'}
on:click={() => (layout = 'horizontal')}
aria-pressed={layout === 'horizontal'}
title="Horizontal timeline"
>
<!-- horizontal bars icon -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="1" width="2" height="14" rx="1" fill="currentColor"/>
<rect x="5" y="1" width="2" height="14" rx="1" fill="currentColor"/>
<rect x="9" y="1" width="2" height="14" rx="1" fill="currentColor"/>
<rect x="13" y="1" width="2" height="14" rx="1" fill="currentColor"/>
</svg>
Horizontal
</button>
</div>
</div>
</header>
<!-- ── Empty state ── -->
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
<!-- ── Vertical layout ── -->
{:else if layout === 'vertical'}
<ol class="v-list">
{#each sortedEntries as entry (entry.id)}
{@const idx = photoIdx[entry.id] ?? 0}
<li class="v-item">
<div class="v-dot" aria-hidden="true"></div>
<article class="entry-card">
<!-- Gallery -->
{#if entry.photos.length > 0}
<div class="gallery">
<img
class="gallery-main"
src={entry.photos[idx]}
alt="{entry.title} photo {idx + 1}"
loading="lazy"
/>
{#if entry.photos.length > 1}
<button
class="gallery-arrow left"
on:click={() => stepPhoto(entry.id, entry.photos.length, -1)}
aria-label="Previous photo"
></button>
<button
class="gallery-arrow right"
on:click={() => stepPhoto(entry.id, entry.photos.length, 1)}
aria-label="Next photo"
></button>
<div class="gallery-dots">
{#each entry.photos as _, i}
<button
class="gallery-pip"
class:active={i === idx}
on:click={() => setPhoto(entry.id, i)}
aria-label="Photo {i + 1}"
></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="entry-body">
<div class="entry-meta">
<time class="entry-date" datetime={entry.date}>{formatDate(entry.date)}</time>
<span class="entry-loc">{entry.location.city}, {entry.location.country}</span>
<span class="trip-badge trip-badge--{entry.tripType}">
{entry.tripType === 'solo' ? '🧍 Solo' : '👥 With Friends'}
</span>
</div>
<h2 class="entry-title">{entry.title}</h2>
{#if entry.memo}
<p class="entry-memo">{entry.memo}</p>
{/if}
<div class="entry-song">
<svg class="song-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
<circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="song-title">{entry.song.title}</span>
<span class="song-sep">·</span>
<span class="song-artist">{entry.song.artist}</span>
</div>
</div>
</article>
</li>
{/each}
</ol>
<!-- ── Horizontal layout ── -->
{:else}
<div class="h-scroll-wrapper">
<div class="h-track-row" aria-hidden="true">
<div class="h-line"></div>
{#each sortedEntries as entry (entry.id)}
<div class="h-dot"></div>
{/each}
</div>
<ol class="h-list">
{#each sortedEntries as entry (entry.id)}
{@const idx = photoIdx[entry.id] ?? 0}
<li class="h-item">
<article class="entry-card h-card">
<!-- Gallery -->
{#if entry.photos.length > 0}
<div class="gallery">
<img
class="gallery-main"
src={entry.photos[idx]}
alt="{entry.title} photo {idx + 1}"
loading="lazy"
/>
{#if entry.photos.length > 1}
<button
class="gallery-arrow left"
on:click={() => stepPhoto(entry.id, entry.photos.length, -1)}
aria-label="Previous photo"
></button>
<button
class="gallery-arrow right"
on:click={() => stepPhoto(entry.id, entry.photos.length, 1)}
aria-label="Next photo"
></button>
<div class="gallery-dots">
{#each entry.photos as _, i}
<button
class="gallery-pip"
class:active={i === idx}
on:click={() => setPhoto(entry.id, i)}
aria-label="Photo {i + 1}"
></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="entry-body">
<div class="entry-meta">
<time class="entry-date" datetime={entry.date}>{formatDate(entry.date)}</time>
<span class="entry-loc">{entry.location.city}, {entry.location.country}</span>
</div>
<h2 class="entry-title">{entry.title}</h2>
{#if entry.memo}
<p class="entry-memo">{entry.memo}</p>
{/if}
<div class="entry-song">
<svg class="song-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
<circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="song-title">{entry.song.title}</span>
<span class="song-sep">·</span>
<span class="song-artist">{entry.song.artist}</span>
</div>
</div>
</article>
</li>
{/each}
</ol>
</div>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
</footer>
</section>
<style>
/* ── Base ───────────────────────────────────────────────── */
.timeline-view {
max-width: 600px;
margin: 0 auto;
padding: 48px 24px 64px;
font-family: var(--sans, system-ui, sans-serif);
}
/* ── Toolbar ────────────────────────────────────────────── */
.toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 48px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border, #e5e4e7);
}
.eyebrow {
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent, #aa3bff);
margin: 0 0 6px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: var(--text-h, #08060d);
margin: 0;
letter-spacing: -0.8px;
}
.controls {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
/* Sort */
.sort-control {
display: flex;
align-items: center;
gap: 8px;
}
.sort-control label {
font-size: 13px;
color: var(--text, #6b6375);
}
select {
font-size: 13px;
padding: 7px 28px 7px 10px;
border: 1px solid var(--border, #e5e4e7);
border-radius: 8px;
background: var(--bg, #fff);
color: var(--text-h, #08060d);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b6375' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
transition: border-color 0.2s;
}
select:focus {
outline: 2px solid var(--accent, #aa3bff);
outline-offset: 2px;
}
/* Layout toggle */
.layout-toggle {
display: flex;
border: 1px solid var(--border, #e5e4e7);
border-radius: 8px;
overflow: hidden;
}
.toggle-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
font-size: 13px;
color: var(--text, #6b6375);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.toggle-btn:first-child {
border-right: 1px solid var(--border, #e5e4e7);
}
.toggle-btn.active {
background: var(--accent-bg, rgba(170,59,255,0.08));
color: var(--accent, #aa3bff);
font-weight: 500;
}
.toggle-btn:hover:not(.active) {
background: var(--code-bg, #f4f3ec);
}
/* ── Shared card ────────────────────────────────────────── */
.entry-card {
border: 1px solid var(--border, #e5e4e7);
border-radius: 14px;
overflow: hidden;
background: var(--bg, #fff);
transition: box-shadow 0.2s;
}
.entry-card:hover {
box-shadow: 0 6px 24px rgba(0,0,0,0.08);
}
.entry-body {
padding: 16px 20px 20px;
text-align: left;
}
.entry-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.entry-date {
font-size: 12px;
color: var(--text, #6b6375);
}
.entry-loc {
font-size: 12px;
color: var(--accent, #aa3bff);
font-weight: 500;
background: var(--accent-bg, rgba(170,59,255,0.08));
padding: 2px 8px;
border-radius: 20px;
}
.entry-title {
font-size: 17px;
font-weight: 600;
color: var(--text-h, #08060d);
margin: 0 0 8px;
letter-spacing: -0.3px;
}
.entry-memo {
font-size: 14px;
line-height: 1.65;
color: var(--text, #6b6375);
margin: 0 0 12px;
}
/* Song row */
.entry-song {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text, #6b6375);
padding-top: 10px;
border-top: 1px solid var(--border, #e5e4e7);
}
.song-icon {
flex-shrink: 0;
color: var(--accent, #aa3bff);
}
.song-title {
font-weight: 500;
color: var(--text-h, #08060d);
}
.song-sep {
opacity: 0.4;
}
/* Trip type badge */
.trip-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 20px;
white-space: nowrap;
}
.trip-badge--solo {
background: rgba(245, 158, 11, 0.12);
color: #b45309;
}
.trip-badge--friends {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
/* ── Gallery ────────────────────────────────────────────── */
.gallery {
position: relative;
overflow: hidden;
background: #000;
}
.gallery-main {
width: 100%;
height: 220px;
object-fit: cover;
display: block;
transition: opacity 0.2s;
}
.gallery-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.45);
color: #fff;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
z-index: 2;
}
.gallery-arrow:hover {
background: rgba(0,0,0,0.7);
}
.gallery-arrow.left { left: 10px; }
.gallery-arrow.right { right: 10px; }
.gallery-dots {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
z-index: 2;
}
.gallery-pip {
width: 6px;
height: 6px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.5);
cursor: pointer;
padding: 0;
transition: background 0.15s, transform 0.15s;
}
.gallery-pip.active {
background: #fff;
transform: scale(1.3);
}
/* ── Vertical timeline ──────────────────────────────────── */
.v-list {
list-style: none;
padding: 0;
margin: 0;
position: relative;
}
.v-list::before {
content: '';
position: absolute;
left: 10px;
top: 11px;
bottom: 11px;
width: 2px;
background: var(--border, #e5e4e7);
border-radius: 1px;
}
.v-item {
display: flex;
gap: 24px;
align-items: flex-start;
padding-bottom: 32px;
}
.v-item:last-child { padding-bottom: 0; }
.v-dot {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent, #aa3bff);
border: 3px solid var(--bg, #fff);
box-shadow: 0 0 0 2px var(--accent, #aa3bff);
margin-top: 8px;
z-index: 1;
}
.v-item .entry-card { flex: 1; }
/* ── Horizontal timeline ────────────────────────────────── */
.h-scroll-wrapper {
overflow-x: auto;
padding-bottom: 12px;
/* hide scrollbar on webkit but keep functional */
scrollbar-width: thin;
scrollbar-color: var(--border, #e5e4e7) transparent;
}
/* Track row: line + dots overlay */
.h-track-row {
position: relative;
display: flex;
align-items: center;
/* match card widths + gaps */
min-width: max-content;
padding: 0 16px;
margin-bottom: -11px; /* overlap with card top */
z-index: 1;
}
.h-line {
position: absolute;
left: 16px;
right: 16px;
top: 50%;
height: 2px;
background: var(--border, #e5e4e7);
border-radius: 1px;
transform: translateY(-50%);
}
.h-dot {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent, #aa3bff);
border: 3px solid var(--bg, #fff);
box-shadow: 0 0 0 2px var(--accent, #aa3bff);
flex-shrink: 0;
z-index: 1;
/* center each dot over its card (card width 240 + gap 24) */
margin-right: calc(240px + 24px - 22px);
}
.h-dot:last-child { margin-right: 0; }
.h-list {
list-style: none;
padding: 12px 16px 0;
margin: 0;
display: flex;
gap: 24px;
min-width: max-content;
}
.h-item { flex-shrink: 0; }
.h-card {
width: 240px;
}
.h-card .gallery-main {
height: 180px;
}
/* ── Footer ─────────────────────────────────────────────── */
.page-footer {
margin-top: 40px;
text-align: center;
font-size: 13px;
color: var(--text, #6b6375);
padding-top: 24px;
border-top: 1px solid var(--border, #e5e4e7);
}
.empty {
text-align: center;
color: var(--text, #6b6375);
padding: 80px 0;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 600px) {
.timeline-view { padding: 32px 16px 48px; }
.page-title { font-size: 26px; }
.v-list::before { left: 8px; }
.v-dot { width: 18px; height: 18px; }
.v-item { gap: 16px; }
.gallery-main { height: 180px; }
}
</style>

View File

@@ -0,0 +1,118 @@
import { writable } from 'svelte/store';
/**
* @typedef {{
* id: string,
* title: string,
* date: string,
* location: { country: string, city: string },
* photos: string[],
* song: { title: string, artist: string },
* tripType: 'solo' | 'friends',
* memo: string
* }} JournalEntry
*/
/** @type {JournalEntry[]} */
const mockEntries = [
{
id: '1',
title: 'First Day in Tokyo',
date: '2024-03-15',
location: { country: 'Japan', city: 'Tokyo' },
photos: [
'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=600&q=80',
'https://images.unsplash.com/photo-1513407030348-c983a97b98d8?w=600&q=80',
'https://images.unsplash.com/photo-1490806843957-31f4c9a91c65?w=600&q=80',
],
song: { title: 'Tokyo', artist: 'Imagine Dragons' },
tripType: 'solo',
memo: 'Got completely lost in Shinjuku — stumbled into a tiny ramen shop with no English menu. The chashu just melted. Worth every wrong turn.',
},
{
id: '2',
title: 'Arashiyama Bamboo Grove',
date: '2024-03-18',
location: { country: 'Japan', city: 'Kyoto' },
photos: [
'https://images.unsplash.com/photo-1528360983277-13d401cdc186?w=600&q=80',
'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=600&q=80',
],
song: { title: 'Spirited Away Suite', artist: 'Joe Hisaishi' },
tripType: 'friends',
memo: 'Arrived at 6am before the crowds. Just me and the wind moving through the bamboo. One of those moments you keep coming back to.',
},
{
id: '3',
title: 'Sunset on Montmartre',
date: '2024-06-02',
location: { country: 'France', city: 'Paris' },
photos: [
'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=600&q=80',
'https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=600&q=80',
'https://images.unsplash.com/photo-1511739001486-6bfe10ce785f?w=600&q=80',
],
song: { title: 'La Vie en Rose', artist: 'Édith Piaf' },
tripType: 'solo',
memo: 'Watched the whole city turn orange from the steps of Sacré-Cœur. A street musician was playing La Vie en Rose. Cliché, perfect.',
},
{
id: '4',
title: 'Inside La Sagrada Família',
date: '2024-06-10',
location: { country: 'Spain', city: 'Barcelona' },
photos: [
'https://images.unsplash.com/photo-1523531294919-4bcd7c65e216?w=600&q=80',
'https://images.unsplash.com/photo-1583422409516-2895a77efded?w=600&q=80',
],
song: { title: 'Spain', artist: 'Chick Corea' },
tripType: 'friends',
memo: 'Nothing prepares you for the light inside. The stained glass turns the whole nave into a kaleidoscope. Gaudí was building a forest.',
},
{
id: '5',
title: 'Central Park in Fall',
date: '2023-10-20',
location: { country: 'USA', city: 'New York' },
photos: [
'https://images.unsplash.com/photo-1534430480872-3498386e7856?w=600&q=80',
'https://images.unsplash.com/photo-1485871981521-5b1fd3805345?w=600&q=80',
'https://images.unsplash.com/photo-1522083165195-3424ed129620?w=600&q=80',
],
song: { title: 'New York, New York', artist: 'Frank Sinatra' },
tripType: 'friends',
memo: 'Peak foliage. Joggers, picnics, a guy playing saxophone near Bethesda Fountain. Hard to believe a city this big wraps around this much quiet.',
},
{
id: '6',
title: 'Wat Pho Reclining Buddha',
date: '2024-01-08',
location: { country: 'Thailand', city: 'Bangkok' },
photos: [
'https://images.unsplash.com/photo-1563492065599-3520f775eeed?w=600&q=80',
'https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=600&q=80',
],
song: { title: 'Elephant', artist: 'Tame Impala' },
tripType: 'solo',
memo: 'Stood in front of the 45m golden Buddha for a long time. The mother-of-pearl inlay on the soles of the feet is impossibly detailed.',
},
];
export const journals = writable(mockEntries);
/**
* @param {Omit<JournalEntry, 'id'>} entry
*/
export function addJournal(entry) {
journals.update((entries) => [
...entries,
{ ...entry, id: crypto.randomUUID() },
]);
}
/**
* @param {string} id
*/
export function removeJournal(id) {
journals.update((entries) => entries.filter((e) => e.id !== id));
}