changed app name, added new journal entry from map and timeline
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user