460 lines
13 KiB
Svelte
460 lines
13 KiB
Svelte
<script>
|
||
import { getEntries } from '../stores/entriesStore.svelte.js';
|
||
import { addEntry, updateEntry } from '../stores/entriesStore.svelte.js';
|
||
import { countryNames } from '../shared/countries.js';
|
||
import { getCitiesForCountry, ALL_CITIES } from '../shared/cities.js';
|
||
import SearchInput from '../shared/SearchInput.svelte';
|
||
import PhotoEditor from './PhotoEditor.svelte';
|
||
|
||
/**
|
||
* 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 isNew = !entry;
|
||
|
||
let cities = $state([...(entry?.location.cities ?? [])]);
|
||
let cityInput = $state('');
|
||
let country = $state(entry?.location.country ?? initialCountry);
|
||
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
||
let days = $state(String(entry?.days ?? ''));
|
||
let tripType = $state(entry?.tripType ?? '');
|
||
let photos = $state([...(entry?.photos ?? [])]);
|
||
let memo = $state(entry?.memo ?? '');
|
||
let transport = $state(entry?.transport ?? '');
|
||
|
||
let errors = $state({
|
||
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
|
||
});
|
||
|
||
function clearErrors() {
|
||
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
|
||
}
|
||
|
||
const transportOptions = [
|
||
{ value: 'flight', label: '✈ Flight' },
|
||
{ value: 'train', label: '🚂 Train' },
|
||
{ value: 'bus', label: '🚌 Bus' },
|
||
{ value: 'car', label: '🚗 Car' },
|
||
{ value: 'ship', label: '🚢 Ship' },
|
||
{ value: 'walk', label: '🚶 Walk' },
|
||
];
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Suggest cities — when a country is selected show only cities from that country.
|
||
let allEntries = $derived(getEntries());
|
||
let cityOptions = $derived(
|
||
country.trim()
|
||
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
|
||
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
|
||
);
|
||
|
||
function addCity(val) {
|
||
const trimmed = (val ?? cityInput).trim();
|
||
if (trimmed && !cities.includes(trimmed)) {
|
||
cities = [...cities, trimmed];
|
||
}
|
||
cityInput = '';
|
||
}
|
||
|
||
function removeCity(c) {
|
||
cities = cities.filter(x => x !== c);
|
||
}
|
||
|
||
async function save() {
|
||
clearErrors();
|
||
let hasError = false;
|
||
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
|
||
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
|
||
if (!date) { errors.date = 'Date is required.'; hasError = true; }
|
||
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
|
||
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
|
||
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
|
||
if (hasError) return;
|
||
|
||
try {
|
||
if (isNew) {
|
||
await addEntry({
|
||
title: `${cities.join(', ')}, ${country}`,
|
||
date,
|
||
days: Number(days),
|
||
tripType,
|
||
memo,
|
||
photos,
|
||
transport,
|
||
location: { cities, country },
|
||
});
|
||
} else {
|
||
await updateEntry(entry.id, {
|
||
date,
|
||
days: Number(days),
|
||
tripType,
|
||
transport,
|
||
memo,
|
||
photos,
|
||
location: { cities, country },
|
||
});
|
||
}
|
||
onBack();
|
||
} catch (err) {
|
||
console.error('Save failed:', err);
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="edit-layout">
|
||
|
||
<header class="edit-topbar">
|
||
<div class="topbar-left">
|
||
<button class="topbar-btn" onclick={onBack}>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||
</svg>
|
||
Back
|
||
</button>
|
||
</div>
|
||
<span class="topbar-title">{isNew ? 'New trip' : 'Edit'}</span>
|
||
<div class="topbar-right">
|
||
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="edit-scroll">
|
||
<form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||
|
||
<div class="row">
|
||
<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 />
|
||
{#if errors.country}<span class="field-error">{errors.country}</span>{/if}
|
||
</div>
|
||
<div class="field">
|
||
<label class="label" for="edit-city">Cities <span class="req">*</span></label>
|
||
<div class="city-input-row">
|
||
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||
</div>
|
||
{#if errors.cities}<span class="field-error">{errors.cities}</span>{/if}
|
||
{#if cities.length > 0}
|
||
<div class="city-tags">
|
||
{#each cities as c}
|
||
<span class="city-tag">
|
||
{c}
|
||
<button type="button" class="city-tag-remove" onclick={() => removeCity(c)}>×</button>
|
||
</span>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="field">
|
||
<label class="label" for="edit-date">Date <span class="req">*</span></label>
|
||
<input id="edit-date" class="input" type="date" bind:value={date} required />
|
||
{#if errors.date}<span class="field-error">{errors.date}</span>{/if}
|
||
</div>
|
||
<div class="field">
|
||
<label class="label" for="edit-days">Days <span class="req">*</span></label>
|
||
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
|
||
{#if errors.days}<span class="field-error">{errors.days}</span>{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="label">Trip type</label>
|
||
<div class="toggle-row">
|
||
<label class="toggle-opt" class:active={tripType === 'solo'}>
|
||
<input type="radio" name="tripType" value="solo" bind:group={tripType} /> Solo
|
||
</label>
|
||
<label class="toggle-opt" class:active={tripType === 'friends'}>
|
||
<input type="radio" name="tripType" value="friends" bind:group={tripType} /> With friends
|
||
</label>
|
||
<label class="toggle-opt" class:active={tripType === 'family'}>
|
||
<input type="radio" name="tripType" value="family" bind:group={tripType} /> With family
|
||
</label>
|
||
</div>
|
||
{#if errors.tripType}<span class="field-error">{errors.tripType}</span>{/if}
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="label">How did you get there?</label>
|
||
<div class="transport-grid">
|
||
{#each transportOptions as opt}
|
||
<label class="transport-opt" class:active={transport === opt.value}>
|
||
<input type="radio" name="transport" value={opt.value} bind:group={transport} />
|
||
{opt.label}
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
{#if errors.transport}<span class="field-error">{errors.transport}</span>{/if}
|
||
</div>
|
||
|
||
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||
|
||
<div class="field">
|
||
<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>
|
||
|
||
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.field-error {
|
||
font-size: 11px;
|
||
color: #dc2626;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.edit-layout {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.edit-topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 20px;
|
||
height: 60px;
|
||
flex-shrink: 0;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg);
|
||
}
|
||
|
||
.topbar-left, .topbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
min-width: 120px;
|
||
}
|
||
.topbar-right { justify-content: flex-end; }
|
||
|
||
.topbar-title {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: var(--text-h);
|
||
}
|
||
|
||
.topbar-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-family: var(--sans);
|
||
font-size: 15px;
|
||
font-weight: 400;
|
||
color: var(--text);
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: 10px;
|
||
padding: 8px 14px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.topbar-btn:hover {
|
||
background: var(--bg-subtle);
|
||
border-color: var(--border);
|
||
color: var(--text-h);
|
||
}
|
||
.topbar-btn--save {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: var(--accent);
|
||
}
|
||
.topbar-btn--save:hover {
|
||
background: var(--accent-dark);
|
||
border-color: var(--accent-dark);
|
||
color: #fff;
|
||
}
|
||
|
||
.edit-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
max-width: 560px;
|
||
margin: 0 auto;
|
||
padding: 36px 48px 80px;
|
||
}
|
||
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.label-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.label {
|
||
font-size: 11px;
|
||
font-weight: 400;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
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;
|
||
}
|
||
|
||
.input {
|
||
font-family: var(--sans);
|
||
font-size: 14px;
|
||
font-weight: 300;
|
||
color: var(--text-h);
|
||
background: var(--bg-subtle);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
width: 100%;
|
||
}
|
||
.input:focus { border-color: var(--accent-border); }
|
||
|
||
.textarea {
|
||
resize: vertical;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.toggle-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toggle-opt {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 300;
|
||
color: var(--text);
|
||
padding: 7px 14px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||
background: var(--bg-subtle);
|
||
}
|
||
.toggle-opt input { display: none; }
|
||
.toggle-opt.active {
|
||
border-color: var(--accent-border);
|
||
background: var(--accent-bg);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.transport-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.transport-opt {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 300;
|
||
color: var(--text);
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||
background: var(--bg-subtle);
|
||
white-space: nowrap;
|
||
}
|
||
.transport-opt input { display: none; }
|
||
.transport-opt.active {
|
||
border-color: var(--accent-border);
|
||
background: var(--accent-bg);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.city-input-row {
|
||
display: flex;
|
||
}
|
||
|
||
.city-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 4px;
|
||
}
|
||
.city-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
font-weight: 300;
|
||
color: var(--accent);
|
||
background: var(--accent-bg);
|
||
border: 1px solid var(--accent-border);
|
||
border-radius: 20px;
|
||
padding: 3px 10px 3px 12px;
|
||
}
|
||
.city-tag-remove {
|
||
background: none;
|
||
border: none;
|
||
color: var(--accent);
|
||
font-size: 15px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
opacity: 0.6;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.city-tag-remove:hover { opacity: 1; }
|
||
|
||
</style>
|