added edit page and revised edit form
This commit is contained in:
127
src/lib/shared/SearchInput.svelte
Normal file
127
src/lib/shared/SearchInput.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script>
|
||||
/**
|
||||
* Searchable combobox input.
|
||||
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
|
||||
*/
|
||||
let { id, value = $bindable(), options, placeholder = '', required = false } = $props();
|
||||
|
||||
let query = $state(value);
|
||||
let open = $state(false);
|
||||
let focused = $state(-1);
|
||||
|
||||
let filtered = $derived(
|
||||
query.trim() === ''
|
||||
? options
|
||||
: options.filter(o => o.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
function select(opt) {
|
||||
query = opt;
|
||||
value = opt;
|
||||
open = false;
|
||||
focused = -1;
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
query = e.currentTarget.value;
|
||||
value = query;
|
||||
open = true;
|
||||
focused = -1;
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); }
|
||||
else if (e.key === 'Enter' && focused >= 0) { e.preventDefault(); select(filtered[focused]); }
|
||||
else if (e.key === 'Escape') { open = false; focused = -1; }
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
setTimeout(() => { open = false; focused = -1; }, 150);
|
||||
}
|
||||
|
||||
// Keep query in sync if value is changed externally
|
||||
$effect(() => { query = value; });
|
||||
</script>
|
||||
|
||||
<div class="combo">
|
||||
<input
|
||||
{id}
|
||||
{required}
|
||||
{placeholder}
|
||||
class="combo-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
value={query}
|
||||
oninput={onInput}
|
||||
onkeydown={onKeydown}
|
||||
onfocus={() => open = true}
|
||||
onblur={onBlur}
|
||||
/>
|
||||
{#if open && filtered.length > 0}
|
||||
<ul class="dropdown" role="listbox">
|
||||
{#each filtered as opt, i}
|
||||
<li
|
||||
class="option"
|
||||
class:highlighted={i === focused}
|
||||
role="option"
|
||||
aria-selected={opt === value}
|
||||
onmousedown={() => select(opt)}
|
||||
>{opt}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.combo {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.combo-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%;
|
||||
}
|
||||
.combo-input:focus { border-color: var(--accent-border); }
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
list-style: none;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.option {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.option:hover, .option.highlighted {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
25
src/lib/shared/countries.js
Normal file
25
src/lib/shared/countries.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export const countryCodeMap = {
|
||||
'Argentina': 'AR', 'Australia': 'AU', 'Austria': 'AT',
|
||||
'Belgium': 'BE', 'Brazil': 'BR',
|
||||
'Canada': 'CA', 'Chile': 'CL', 'China': 'CN', 'Croatia': 'HR',
|
||||
'Czech Republic': 'CZ', 'Denmark': 'DK', 'Egypt': 'EG',
|
||||
'Finland': 'FI', 'France': 'FR', 'Germany': 'DE', 'Greece': 'GR',
|
||||
'Hungary': 'HU', 'India': 'IN', 'Indonesia': 'ID', 'Italy': 'IT',
|
||||
'Japan': 'JP', 'Kenya': 'KE',
|
||||
'Malaysia': 'MY', 'Mexico': 'MX', 'Morocco': 'MA',
|
||||
'Netherlands': 'NL', 'New Zealand': 'NZ', 'Norway': 'NO',
|
||||
'Peru': 'PE', 'Poland': 'PL', 'Portugal': 'PT',
|
||||
'Singapore': 'SG', 'South Africa': 'ZA', 'South Korea': 'KR',
|
||||
'Spain': 'ES', 'Sweden': 'SE', 'Switzerland': 'CH',
|
||||
'Taiwan': 'TW', 'Thailand': 'TH', 'Turkey': 'TR',
|
||||
'UK': 'GB', 'USA': 'US', 'Vietnam': 'VN',
|
||||
};
|
||||
|
||||
export const countryNames = Object.keys(countryCodeMap).sort();
|
||||
|
||||
/** @param {string} country */
|
||||
export function flagEmoji(country) {
|
||||
const code = countryCodeMap[country];
|
||||
if (!code) return '';
|
||||
return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join('');
|
||||
}
|
||||
@@ -116,3 +116,8 @@ export function addJournal(entry) {
|
||||
export function removeJournal(id) {
|
||||
journals.update((entries) => entries.filter((e) => e.id !== id));
|
||||
}
|
||||
|
||||
/** @param {JournalEntry} updated */
|
||||
export function updateJournal(updated) {
|
||||
journals.update((entries) => entries.map((e) => e.id === updated.id ? updated : e));
|
||||
}
|
||||
|
||||
88
src/lib/timeline/DeleteConfirm.svelte
Normal file
88
src/lib/timeline/DeleteConfirm.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
let { entry, onConfirm, onCancel } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="dialog">
|
||||
<h2 class="title">Delete entry?</h2>
|
||||
<p class="body">
|
||||
<strong>{entry.location.city}, {entry.location.country}</strong> — {entry.date.slice(0, 4)} will be permanently removed.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-cancel" onclick={onCancel}>Cancel</button>
|
||||
<button class="btn btn-delete" onclick={onConfirm}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 28px 32px;
|
||||
width: 360px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
color: var(--text-h);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.btn-delete:hover {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
268
src/lib/timeline/EditForm.svelte
Normal file
268
src/lib/timeline/EditForm.svelte
Normal file
@@ -0,0 +1,268 @@
|
||||
<script>
|
||||
import { get } from 'svelte/store';
|
||||
import { journals, 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();
|
||||
|
||||
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);
|
||||
|
||||
// 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 },
|
||||
});
|
||||
onBack();
|
||||
}
|
||||
</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">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-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>
|
||||
|
||||
<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 />
|
||||
</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 />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label class="label" for="edit-song-title">Song title</label>
|
||||
<input id="edit-song-title" class="input" type="text" bind:value={songTitle} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="edit-song-artist">Artist</label>
|
||||
<input id="edit-song-artist" class="input" type="text" bind:value={songArtist} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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: 52px;
|
||||
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: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.topbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
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 {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,19 +1,16 @@
|
||||
<script>
|
||||
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void }} */
|
||||
let { entry, onBack } = $props();
|
||||
import { removeJournal } from '../stores/journalStore.js';
|
||||
import { flagEmoji } from '../shared/countries.js';
|
||||
import DeleteConfirm from './DeleteConfirm.svelte';
|
||||
|
||||
const countryCodeMap = {
|
||||
'Japan': 'JP', 'France': 'FR', 'Spain': 'ES', 'USA': 'US',
|
||||
'Thailand': 'TH', 'Germany': 'DE', 'Italy': 'IT', 'UK': 'GB',
|
||||
'Australia': 'AU', 'Canada': 'CA', 'China': 'CN', 'India': 'IN',
|
||||
'Brazil': 'BR', 'Mexico': 'MX', 'Portugal': 'PT', 'Netherlands': 'NL',
|
||||
'Greece': 'GR', 'Turkey': 'TR', 'Vietnam': 'VN', 'Indonesia': 'ID',
|
||||
'South Korea': 'KR', 'Singapore': 'SG', 'Taiwan': 'TW', 'New Zealand': 'NZ',
|
||||
};
|
||||
function flagEmoji(country) {
|
||||
const code = countryCodeMap[country];
|
||||
if (!code) return '';
|
||||
return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join('');
|
||||
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
|
||||
let { entry, onBack, onEdit } = $props();
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
function handleDelete() {
|
||||
removeJournal(entry.id);
|
||||
onBack();
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
@@ -25,6 +22,10 @@
|
||||
let lightboxSrc = $state(null);
|
||||
</script>
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<DeleteConfirm {entry} onConfirm={handleDelete} onCancel={() => showDeleteConfirm = false} />
|
||||
{/if}
|
||||
|
||||
<!-- Lightbox -->
|
||||
{#if lightboxSrc}
|
||||
<div class="lightbox" onclick={() => lightboxSrc = null} role="button" tabindex="0"
|
||||
@@ -55,14 +56,14 @@
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button class="topbar-btn" title="Edit entry">
|
||||
<button class="topbar-btn" title="Edit entry" onclick={onEdit}>
|
||||
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="topbar-btn topbar-btn--danger" title="Delete entry">
|
||||
<button class="topbar-btn topbar-btn--danger" title="Delete entry" onclick={() => showDeleteConfirm = true}>
|
||||
<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="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
|
||||
189
src/lib/timeline/PhotoEditor.svelte
Normal file
189
src/lib/timeline/PhotoEditor.svelte
Normal file
@@ -0,0 +1,189 @@
|
||||
<script>
|
||||
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
|
||||
let { photos, onchange } = $props();
|
||||
|
||||
let fileInput;
|
||||
|
||||
function remove(index) {
|
||||
const next = photos.filter((_, i) => i !== index);
|
||||
onchange(next);
|
||||
}
|
||||
|
||||
async function addFiles(e) {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (!files.length) return;
|
||||
|
||||
const dataUrls = await Promise.all(files.map(fileToDataUrl));
|
||||
onchange([...photos, ...dataUrls]);
|
||||
|
||||
// reset so the same file can be picked again
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
|
||||
/** @param {File} file */
|
||||
function fileToDataUrl(file) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(/** @type {string} */ (e.target.result));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="photo-editor">
|
||||
<div class="label-row">
|
||||
<span class="label">Photos</span>
|
||||
<button type="button" class="add-btn" onclick={() => fileInput.click()}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Add photos
|
||||
</button>
|
||||
<input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden />
|
||||
</div>
|
||||
|
||||
{#if photos.length === 0}
|
||||
<button type="button" class="empty-zone" onclick={() => fileInput.click()}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="M21 15l-5-5L5 21"/>
|
||||
</svg>
|
||||
<span>Click to add photos</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each photos as src, i (src + i)}
|
||||
<div class="cell">
|
||||
<img {src} alt="photo {i + 1}" />
|
||||
<button type="button" class="remove-btn" onclick={() => remove(i)} aria-label="Remove photo">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="button" class="add-cell" onclick={() => fileInput.click()}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.photo-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-sub);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.add-btn:hover { background: rgba(124,58,237,0.12); }
|
||||
|
||||
.empty-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 120px;
|
||||
border: 1.5px dashed var(--border-bright);
|
||||
border-radius: 10px;
|
||||
color: var(--text-sub);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
background: var(--bg-subtle);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.empty-zone:hover { border-color: var(--accent-border); color: var(--accent); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.cell:hover .remove-btn { opacity: 1; }
|
||||
.remove-btn:hover { background: rgba(220,38,38,0.85); }
|
||||
|
||||
.add-cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
border: 1.5px dashed var(--border-bright);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-sub);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
|
||||
</style>
|
||||
@@ -1,28 +1,9 @@
|
||||
<script>
|
||||
import { flagEmoji } from '../shared/countries.js';
|
||||
|
||||
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onClick: () => void }} */
|
||||
let { entry, onClick } = $props();
|
||||
|
||||
/** Convert country name to flag emoji via ISO 3166-1 alpha-2 */
|
||||
const countryCodeMap = {
|
||||
'Japan': 'JP', 'France': 'FR', 'Spain': 'ES', 'USA': 'US',
|
||||
'Thailand': 'TH', 'Germany': 'DE', 'Italy': 'IT', 'UK': 'GB',
|
||||
'Australia': 'AU', 'Canada': 'CA', 'China': 'CN', 'India': 'IN',
|
||||
'Brazil': 'BR', 'Mexico': 'MX', 'Portugal': 'PT', 'Netherlands': 'NL',
|
||||
'Greece': 'GR', 'Turkey': 'TR', 'Vietnam': 'VN', 'Indonesia': 'ID',
|
||||
'Malaysia': 'MY', 'Singapore': 'SG', 'South Korea': 'KR', 'Taiwan': 'TW',
|
||||
'New Zealand': 'NZ', 'Argentina': 'AR', 'Chile': 'CL', 'Peru': 'PE',
|
||||
'Morocco': 'MA', 'Egypt': 'EG', 'Kenya': 'KE', 'South Africa': 'ZA',
|
||||
'Sweden': 'SE', 'Norway': 'NO', 'Denmark': 'DK', 'Finland': 'FI',
|
||||
'Switzerland': 'CH', 'Austria': 'AT', 'Belgium': 'BE', 'Poland': 'PL',
|
||||
'Czech Republic': 'CZ', 'Hungary': 'HU', 'Croatia': 'HR',
|
||||
};
|
||||
|
||||
function flagEmoji(country) {
|
||||
const code = countryCodeMap[country];
|
||||
if (!code) return '';
|
||||
return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join('');
|
||||
}
|
||||
|
||||
function formatDate(/** @type {string} */ iso) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
import TimelineCard from './TimelineCard.svelte';
|
||||
import JournalDetail from './JournalDetail.svelte';
|
||||
import JournalSummary from './JournalSummary.svelte';
|
||||
import EditForm from './EditForm.svelte';
|
||||
|
||||
/** @type {import('../stores/journalStore.js').JournalEntry|null} */
|
||||
let { onDetailChange = () => {} } = $props();
|
||||
let selected = $state(null);
|
||||
let selectedId = $state(/** @type {string|null} */(null));
|
||||
let view = $state(/** @type {'list'|'detail'|'edit'} */('list'));
|
||||
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
|
||||
|
||||
let entries = $state(get(journals));
|
||||
$effect(() => {
|
||||
@@ -37,9 +39,17 @@
|
||||
|
||||
<div class="journal-page">
|
||||
|
||||
{#if selected}
|
||||
{#if view === 'edit' && selected}
|
||||
<div class="detail-scroll">
|
||||
<JournalDetail entry={selected} onBack={() => { selected = null; onDetailChange(false); }} />
|
||||
<EditForm entry={selected} onBack={() => { view = 'detail'; }} />
|
||||
</div>
|
||||
{:else if view === 'detail' && selected}
|
||||
<div class="detail-scroll">
|
||||
<JournalDetail
|
||||
entry={selected}
|
||||
onBack={() => { selectedId = null; view = 'list'; onDetailChange(false); }}
|
||||
onEdit={() => { view = 'edit'; }}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="right-panel">
|
||||
@@ -69,7 +79,7 @@
|
||||
<span class="year-label">{getYear(entry.date)}</span>
|
||||
</li>
|
||||
{/if}
|
||||
<TimelineCard {entry} onClick={() => { selected = entry; onDetailChange(true); }} />
|
||||
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user