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

View File

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

View File

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

View File

@@ -1,39 +1,73 @@
<script>
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 SearchInput from '../shared/SearchInput.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 country = $state(entry.location.country);
let date = $state(entry.date);
let days = $state(String(entry.days));
let tripType = $state(entry.tripType);
let photos = $state([...entry.photos]);
let memo = $state(entry.memo);
let songTitle = $state(entry.song.title);
let songArtist = $state(entry.song.artist);
let isNew = !entry;
let city = $state(entry?.location.city ?? '');
let country = $state(entry?.location.country ?? initialCountry);
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
let days = $state(String(entry?.days ?? 1));
let tripType = $state(entry?.tripType ?? 'solo');
let photos = $state([...(entry?.photos ?? [])]);
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(
[...new Set(get(journals).map(e => e.location.city))].sort()
);
function save() {
updateJournal({
...entry,
date,
days: Number(days),
tripType,
memo,
photos,
location: { city, country },
song: { title: songTitle, artist: songArtist },
});
if (isNew) {
addJournal({
title: `${city}, ${country}`,
date,
days: Number(days),
tripType,
memo,
photos,
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();
}
</script>
@@ -49,7 +83,7 @@
Back
</button>
</div>
<span class="topbar-title">Edit</span>
<span class="topbar-title">{isNew ? 'New entry' : 'Edit'}</span>
<div class="topbar-right">
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
</div>
@@ -59,14 +93,14 @@
<form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}>
<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">
<label class="label" for="edit-country">Country <span class="req">*</span></label>
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
</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 class="row">
@@ -95,8 +129,11 @@
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
<div class="field">
<label class="label" for="edit-memo">How was it?</label>
<textarea id="edit-memo" class="input textarea" rows="4" bind:value={memo}></textarea>
<div class="label-row">
<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 class="row">
@@ -206,6 +243,12 @@
gap: 6px;
}
.label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.label {
font-size: 11px;
font-weight: 400;
@@ -214,6 +257,15 @@
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 {
color: var(--accent);
font-size: 11px;

View File

@@ -7,9 +7,18 @@
import JournalSummary from './JournalSummary.svelte';
import EditForm from './EditForm.svelte';
let { onDetailChange = () => {} } = $props();
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
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 entries = $state(get(journals));
@@ -39,7 +48,11 @@
<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">
<EditForm entry={selected} onBack={() => { view = 'detail'; }} />
</div>
@@ -54,7 +67,15 @@
{:else}
<div class="right-panel">
<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} />
@@ -218,11 +239,37 @@
}
.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 {
font-size: var(--text-xl);
font-weight: 400;
color: var(--text-h);
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>

View File

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