changed app name, added new journal entry from map and timeline

This commit is contained in:
Haeri Kim
2026-06-14 10:59:59 +09:00
parent cdf3643622
commit 8422c6e34f
6 changed files with 162 additions and 42 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>map-journal</title> <title>Journi</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -6,18 +6,28 @@
let screen = $state('worldmap'); let screen = $state('worldmap');
let inDetail = $state(false); let inDetail = $state(false);
let pendingCountry = $state('');
function handleCountryClick(name) {
pendingCountry = name;
screen = 'timeline';
}
</script> </script>
<Layout {screen} onNavigate={(s) => (screen = s)} hideTopBar={inDetail}> <Layout {screen} onNavigate={(s) => (screen = s)} hideTopBar={inDetail}>
{#if screen === 'worldmap'} {#if screen === 'worldmap'}
<div class="worldmap-page"> <div class="worldmap-page">
<div class="map-area"> <div class="map-area">
<WorldMap /> <WorldMap onCountryClick={handleCountryClick} />
</div> </div>
<StatsPanel /> <StatsPanel />
</div> </div>
{:else} {:else}
<TimelineView onDetailChange={(v) => (inDetail = v)} /> <TimelineView
onDetailChange={(v) => (inDetail = v)}
{pendingCountry}
onNewEntryClear={() => (pendingCountry = '')}
/>
{/if} {/if}
</Layout> </Layout>

View File

@@ -3,7 +3,9 @@
</script> </script>
<nav class="topbar"> <nav class="topbar">
<span class="logo">Map Journal</span> <div class="logo-area">
<span class="logo">Journi</span>
</div>
<div class="nav-links"> <div class="nav-links">
<button class="nav-btn" class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Map</button> <button class="nav-btn" class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Map</button>
<button class="nav-btn" class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button> <button class="nav-btn" class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button>
@@ -23,12 +25,18 @@
z-index: 10; z-index: 10;
} }
.logo-area {
display: flex;
align-items: center;
gap: 8px;
}
.logo { .logo {
font-family: var(--heading); font-family: var(--heading);
font-size: 14px; font-size: 22px;
font-weight: 400; font-weight: 600;
color: var(--text-h); color: var(--text-h);
letter-spacing: 0.01em; letter-spacing: -0.5px;
} }
.nav-links { .nav-links {

View File

@@ -1,39 +1,73 @@
<script> <script>
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { journals, updateJournal } from '../stores/journalStore.js'; import { journals, addJournal, updateJournal } from '../stores/journalStore.js';
import { countryNames } from '../shared/countries.js'; import { countryNames } from '../shared/countries.js';
import SearchInput from '../shared/SearchInput.svelte'; import SearchInput from '../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte'; import PhotoEditor from './PhotoEditor.svelte';
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void }} */ /**
let { entry, onBack } = $props(); * entry = null → "new entry" mode
* entry = {...} → "edit" mode
* @type {{ entry?: import('../stores/journalStore.js').JournalEntry | null, initialCountry?: string, onBack: () => void }}
*/
let { entry = null, initialCountry = '', onBack } = $props();
let city = $state(entry.location.city); let isNew = !entry;
let country = $state(entry.location.country);
let date = $state(entry.date); let city = $state(entry?.location.city ?? '');
let days = $state(String(entry.days)); let country = $state(entry?.location.country ?? initialCountry);
let tripType = $state(entry.tripType); let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
let photos = $state([...entry.photos]); let days = $state(String(entry?.days ?? 1));
let memo = $state(entry.memo); let tripType = $state(entry?.tripType ?? 'solo');
let songTitle = $state(entry.song.title); let photos = $state([...(entry?.photos ?? [])]);
let songArtist = $state(entry.song.artist); let memo = $state(entry?.memo ?? '');
let songTitle = $state(entry?.song.title ?? '');
let songArtist = $state(entry?.song.artist ?? '');
const MEMO_MAX = 100;
let wordCount = $derived(memo.trim() === '' ? 0 : memo.trim().split(/\s+/).length);
let memoOverLimit = $derived(wordCount > MEMO_MAX);
function onMemoInput(e) {
const raw = e.currentTarget.value;
const words = raw.trim() === '' ? [] : raw.trim().split(/\s+/);
if (words.length > MEMO_MAX) {
// keep first 100 words, preserve trailing space if user is mid-word
memo = words.slice(0, MEMO_MAX).join(' ');
e.currentTarget.value = memo;
} else {
memo = raw;
}
}
// City suggestions from existing entries (deduplicated)
let cityOptions = $derived( let cityOptions = $derived(
[...new Set(get(journals).map(e => e.location.city))].sort() [...new Set(get(journals).map(e => e.location.city))].sort()
); );
function save() { function save() {
updateJournal({ if (isNew) {
...entry, addJournal({
date, title: `${city}, ${country}`,
days: Number(days), date,
tripType, days: Number(days),
memo, tripType,
photos, memo,
location: { city, country }, photos,
song: { title: songTitle, artist: songArtist }, location: { city, country },
}); song: { title: songTitle, artist: songArtist },
});
} else {
updateJournal({
...entry,
date,
days: Number(days),
tripType,
memo,
photos,
location: { city, country },
song: { title: songTitle, artist: songArtist },
});
}
onBack(); onBack();
} }
</script> </script>
@@ -49,7 +83,7 @@
Back Back
</button> </button>
</div> </div>
<span class="topbar-title">Edit</span> <span class="topbar-title">{isNew ? 'New entry' : 'Edit'}</span>
<div class="topbar-right"> <div class="topbar-right">
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button> <button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
</div> </div>
@@ -59,14 +93,14 @@
<form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}> <form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}>
<div class="row"> <div class="row">
<div class="field">
<label class="label" for="edit-city">City <span class="req">*</span></label>
<SearchInput id="edit-city" bind:value={city} options={cityOptions} required />
</div>
<div class="field"> <div class="field">
<label class="label" for="edit-country">Country <span class="req">*</span></label> <label class="label" for="edit-country">Country <span class="req">*</span></label>
<SearchInput id="edit-country" bind:value={country} options={countryNames} required /> <SearchInput id="edit-country" bind:value={country} options={countryNames} required />
</div> </div>
<div class="field">
<label class="label" for="edit-city">City <span class="req">*</span></label>
<SearchInput id="edit-city" bind:value={city} options={cityOptions} required />
</div>
</div> </div>
<div class="row"> <div class="row">
@@ -95,8 +129,11 @@
<PhotoEditor {photos} onchange={(p) => (photos = p)} /> <PhotoEditor {photos} onchange={(p) => (photos = p)} />
<div class="field"> <div class="field">
<label class="label" for="edit-memo">How was it?</label> <div class="label-row">
<textarea id="edit-memo" class="input textarea" rows="4" bind:value={memo}></textarea> <label class="label" for="edit-memo">How was it?</label>
<span class="char-count" class:over={memoOverLimit}>{wordCount} / {MEMO_MAX} words</span>
</div>
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="4" value={memo} oninput={onMemoInput}></textarea>
</div> </div>
<div class="row"> <div class="row">
@@ -206,6 +243,12 @@
gap: 6px; gap: 6px;
} }
.label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.label { .label {
font-size: 11px; font-size: 11px;
font-weight: 400; font-weight: 400;
@@ -214,6 +257,15 @@
color: var(--text-sub); color: var(--text-sub);
} }
.char-count {
font-size: 11px;
font-weight: 300;
color: var(--text-sub);
transition: color 0.15s;
}
.char-count.over { color: #dc2626; }
.input-over { border-color: #fca5a5; }
.req { .req {
color: var(--accent); color: var(--accent);
font-size: 11px; font-size: 11px;

View File

@@ -7,9 +7,18 @@
import JournalSummary from './JournalSummary.svelte'; import JournalSummary from './JournalSummary.svelte';
import EditForm from './EditForm.svelte'; import EditForm from './EditForm.svelte';
let { onDetailChange = () => {} } = $props(); let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null)); let selectedId = $state(/** @type {string|null} */(null));
let view = $state(/** @type {'list'|'detail'|'edit'} */('list')); let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
// When App passes a country from the map, open new-entry form automatically
$effect(() => {
if (pendingCountry) {
selectedId = null;
view = 'new';
onNewEntryClear();
}
});
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null); let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
let entries = $state(get(journals)); let entries = $state(get(journals));
@@ -39,7 +48,11 @@
<div class="journal-page"> <div class="journal-page">
{#if view === 'edit' && selected} {#if view === 'new'}
<div class="detail-scroll">
<EditForm initialCountry={pendingCountry} onBack={() => { view = 'list'; onDetailChange(false); }} />
</div>
{:else if view === 'edit' && selected}
<div class="detail-scroll"> <div class="detail-scroll">
<EditForm entry={selected} onBack={() => { view = 'detail'; }} /> <EditForm entry={selected} onBack={() => { view = 'detail'; }} />
</div> </div>
@@ -54,7 +67,15 @@
{:else} {:else}
<div class="right-panel"> <div class="right-panel">
<div class="right-inner"> <div class="right-inner">
<h1 class="page-title">My Journey</h1> <div class="page-header">
<h1 class="page-title">My Journey</h1>
<button class="new-btn" onclick={() => { view = 'new'; }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>
New entry
</button>
</div>
<JournalSummary entries={sortedEntries} /> <JournalSummary entries={sortedEntries} />
@@ -218,11 +239,37 @@
} }
.sort-select:hover { border-color: var(--border-bright); color: var(--text-h); } .sort-select:hover { border-color: var(--border-bright); color: var(--text-h); }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title { .page-title {
font-size: var(--text-xl); font-size: var(--text-xl);
font-weight: 400; font-weight: 400;
color: var(--text-h); color: var(--text-h);
letter-spacing: -0.5px; letter-spacing: -0.5px;
margin: 0 0 16px; margin: 0;
} }
.new-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
</style> </style>

View File

@@ -5,6 +5,8 @@
import worldData from 'world-atlas/countries-50m.json'; import worldData from 'world-atlas/countries-50m.json';
import { getSelected, toggle, setTotalCount } from '../layout/selection.svelte.js'; import { getSelected, toggle, setTotalCount } from '../layout/selection.svelte.js';
let { onCountryClick = (_name) => {} } = $props();
const TERRITORY_PARENT = { const TERRITORY_PARENT = {
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826', '016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246', '184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
@@ -60,6 +62,7 @@
.on('click', (event, d) => { .on('click', (event, d) => {
toggle(effId(d)); toggle(effId(d));
updateFill(d3.select(event.currentTarget)); updateFill(d3.select(event.currentTarget));
onCountryClick(d.properties.name);
}) })
.on('mouseenter', (event, d) => { .on('mouseenter', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa'); d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');