added sharable stats

This commit is contained in:
haerikimmm
2026-06-15 10:20:55 +09:00
parent 8422c6e34f
commit 6cee6095ed
8 changed files with 646 additions and 71 deletions

7
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"d3": "^7.9.0",
"flag-icons": "^7.5.0",
"html-to-image": "^1.11.13",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
@@ -1004,6 +1005,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",

View File

@@ -16,6 +16,7 @@
"dependencies": {
"d3": "^7.9.0",
"flag-icons": "^7.5.0",
"html-to-image": "^1.11.13",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
}

View File

@@ -7,7 +7,7 @@ import { writable } from 'svelte/store';
* date: string,
* location: { country: string, city: string },
* photos: string[],
* song: { title: string, artist: string },
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
* tripType: 'solo' | 'friends',
* days: number,
* memo: string
@@ -26,7 +26,7 @@ const mockEntries = [
'https://images.unsplash.com/photo-1513407030348-c983a97b98d8?w=600&q=80',
'https://images.unsplash.com/photo-1490806843957-31f4c9a91c65?w=600&q=80',
],
song: { title: 'Tokyo', artist: 'Imagine Dragons' },
transport: 'flight',
tripType: 'solo',
days: 5,
memo: 'Got completely lost in Shinjuku — stumbled into a tiny ramen shop with no English menu. The chashu just melted. Worth every wrong turn.',
@@ -40,7 +40,7 @@ const mockEntries = [
'https://images.unsplash.com/photo-1528360983277-13d401cdc186?w=600&q=80',
'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=600&q=80',
],
song: { title: 'Spirited Away Suite', artist: 'Joe Hisaishi' },
transport: 'train',
tripType: 'friends',
days: 3,
memo: 'Arrived at 6am before the crowds. Just me and the wind moving through the bamboo. One of those moments you keep coming back to.',
@@ -55,7 +55,7 @@ const mockEntries = [
'https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=600&q=80',
'https://images.unsplash.com/photo-1511739001486-6bfe10ce785f?w=600&q=80',
],
song: { title: 'La Vie en Rose', artist: 'Édith Piaf' },
transport: 'flight',
tripType: 'solo',
days: 7,
memo: 'Watched the whole city turn orange from the steps of Sacré-Cœur. A street musician was playing La Vie en Rose. Cliché, perfect.',
@@ -69,7 +69,7 @@ const mockEntries = [
'https://images.unsplash.com/photo-1523531294919-4bcd7c65e216?w=600&q=80',
'https://images.unsplash.com/photo-1583422409516-2895a77efded?w=600&q=80',
],
song: { title: 'Spain', artist: 'Chick Corea' },
transport: 'flight',
tripType: 'friends',
days: 4,
memo: 'Nothing prepares you for the light inside. The stained glass turns the whole nave into a kaleidoscope. Gaudí was building a forest.',
@@ -84,7 +84,7 @@ const mockEntries = [
'https://images.unsplash.com/photo-1485871981521-5b1fd3805345?w=600&q=80',
'https://images.unsplash.com/photo-1522083165195-3424ed129620?w=600&q=80',
],
song: { title: 'New York, New York', artist: 'Frank Sinatra' },
transport: 'car',
tripType: 'friends',
days: 6,
memo: 'Peak foliage. Joggers, picnics, a guy playing saxophone near Bethesda Fountain. Hard to believe a city this big wraps around this much quiet.',
@@ -98,7 +98,7 @@ const mockEntries = [
'https://images.unsplash.com/photo-1563492065599-3520f775eeed?w=600&q=80',
'https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=600&q=80',
],
song: { title: 'Elephant', artist: 'Tame Impala' },
transport: 'ship',
tripType: 'solo',
days: 2,
memo: 'Stood in front of the 45m golden Buddha for a long time. The mother-of-pearl inlay on the soles of the feet is impossibly detailed.',

View File

@@ -21,8 +21,16 @@
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 ?? '');
let transport = $state(entry?.transport ?? 'flight');
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);
@@ -53,8 +61,8 @@
tripType,
memo,
photos,
transport,
location: { city, country },
song: { title: songTitle, artist: songArtist },
});
} else {
updateJournal({
@@ -62,10 +70,10 @@
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { city, country },
song: { title: songTitle, artist: songArtist },
});
}
onBack();
@@ -126,6 +134,18 @@
</div>
</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>
</div>
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
<div class="field">
@@ -136,16 +156,6 @@
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="4" value={memo} oninput={onMemoInput}></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>
@@ -317,4 +327,32 @@
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);
}
</style>

View File

@@ -125,23 +125,6 @@
<p class="answer memo">{entry.memo}</p>
</div>
<div class="qa-item">
<p class="question">Trip soundtrack</p>
<div class="song-answer">
<div class="song-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
</div>
<div>
<p class="song-title">{entry.song.title}</p>
<p class="song-artist">{entry.song.artist}</p>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -340,34 +323,6 @@
line-height: 1.75;
}
.song-answer {
display: flex;
align-items: center;
gap: 10px;
}
.song-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-bg);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.song-title {
font-size: 14px;
font-weight: 400;
color: var(--text-h);
}
.song-artist {
font-size: 12px;
font-weight: 300;
color: var(--text-sub);
margin-top: 2px;
}
/* ── Lightbox ── */
.lightbox {
position: fixed;

View File

@@ -0,0 +1,483 @@
<script>
import { toPng } from 'html-to-image';
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClose: () => void }} */
let { entries, onClose } = $props();
let cardEl = $state(null);
let downloading = $state(false);
const continentMap = {
'Japan': 'Asia', 'South Korea': 'Asia', 'China': 'Asia', 'Thailand': 'Asia',
'Vietnam': 'Asia', 'Indonesia': 'Asia', 'Malaysia': 'Asia', 'Singapore': 'Asia',
'India': 'Asia', 'Taiwan': 'Asia', 'Philippines': 'Asia', 'Cambodia': 'Asia',
'Nepal': 'Asia', 'Bangladesh': 'Asia',
'France': 'Europe', 'Spain': 'Europe', 'Italy': 'Europe', 'Germany': 'Europe',
'UK': 'Europe', 'Netherlands': 'Europe', 'Portugal': 'Europe', 'Greece': 'Europe',
'Sweden': 'Europe', 'Norway': 'Europe', 'Denmark': 'Europe', 'Finland': 'Europe',
'Switzerland': 'Europe', 'Austria': 'Europe', 'Belgium': 'Europe', 'Poland': 'Europe',
'Czech Republic': 'Europe', 'Hungary': 'Europe', 'Croatia': 'Europe', 'Turkey': 'Europe',
'USA': 'N. America', 'Canada': 'N. America', 'Mexico': 'N. America',
'Brazil': 'S. America', 'Argentina': 'S. America', 'Chile': 'S. America',
'Peru': 'S. America', 'Colombia': 'S. America',
'Australia': 'Oceania', 'New Zealand': 'Oceania',
'Morocco': 'Africa', 'Egypt': 'Africa', 'Kenya': 'Africa', 'South Africa': 'Africa',
};
const flightHoursMap = {
'Japan': 13, 'South Korea': 13, 'China': 12, 'Thailand': 11, 'Vietnam': 12,
'Indonesia': 14, 'Malaysia': 11, 'Singapore': 11, 'India': 9, 'Taiwan': 13,
'France': 9, 'Spain': 10, 'Italy': 10, 'Germany': 9, 'UK': 8, 'Netherlands': 9,
'Portugal': 9, 'Greece': 11, 'Sweden': 10, 'Norway': 9, 'Switzerland': 9,
'Turkey': 11, 'Austria': 10, 'Belgium': 9, 'Poland': 10,
'USA': 14, 'Canada': 13, 'Mexico': 13,
'Brazil': 10, 'Argentina': 12, 'Chile': 13,
'Australia': 20, 'New Zealand': 23,
'Morocco': 10, 'Egypt': 12, 'Kenya': 14, 'South Africa': 16,
};
let stats = $derived.by(() => {
if (entries.length === 0) return null;
const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))];
const cities = [...new Set(entries.map(e => e.location.city))];
// Continent days
const contDays = {};
for (const e of entries) {
const cont = continentMap[e.location.country] ?? 'Other';
contDays[cont] = (contDays[cont] ?? 0) + e.days;
}
const topContinent = Object.entries(contDays).sort((a, b) => b[1] - a[1])[0];
// Longest trip
const longest = [...entries].sort((a, b) => b.days - a.days)[0];
// Date range
const dates = entries.map(e => new Date(e.date));
const firstDate = new Date(Math.min(...dates));
const lastDate = new Date(Math.max(...dates));
const spanDays = Math.round((lastDate - firstDate) / 86400000);
const spanMonths = Math.round(spanDays / 30);
// Flight hours estimate
const flightTrips = entries.filter(e => e.transport === 'flight');
const flightHrs = flightTrips.reduce((s, e) => s + (flightHoursMap[e.location.country] ?? 10), 0);
// Solo vs friends
const soloCount = entries.filter(e => e.tripType === 'solo').length;
const friendCount = entries.filter(e => e.tripType === 'friends').length;
// Most visited country
const countryCounts = {};
for (const e of entries) countryCounts[e.location.country] = (countryCounts[e.location.country] ?? 0) + 1;
const favCountry = Object.entries(countryCounts).sort((a, b) => b[1] - a[1])[0];
const yearStart = firstDate.getFullYear();
const yearEnd = lastDate.getFullYear();
return {
totalDays, countries, cities, contDays, topContinent,
longest, flightHrs, soloCount, friendCount,
favCountry, spanMonths, yearStart, yearEnd,
};
});
async function download() {
if (!cardEl) return;
downloading = true;
try {
const dataUrl = await toPng(cardEl, { pixelRatio: 3 });
const a = document.createElement('a');
a.download = 'my-journey.png';
a.href = dataUrl;
a.click();
} finally {
downloading = false;
}
}
function fmtYear(y1, y2) {
return y1 === y2 ? `${y1}` : `${y1} ${y2}`;
}
</script>
<!-- Overlay -->
<div class="overlay" role="dialog" aria-modal="true">
<div class="overlay-inner">
<!-- Controls -->
<div class="controls">
<button class="ctrl-btn" onclick={onClose}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
Close
</button>
<button class="ctrl-btn ctrl-btn--primary" onclick={download} disabled={downloading}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
{downloading ? 'Saving…' : 'Save as PNG'}
</button>
</div>
<!-- The card (Instagram story 9:16) -->
{#if stats}
<div class="card-wrap">
<div class="card" bind:this={cardEl}>
<!-- Background layers -->
<div class="bg-gradient"></div>
<div class="bg-grid"></div>
<!-- Header -->
<div class="card-header">
<span class="card-brand">MAP JOURNAL</span>
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
</div>
<!-- Hero stat -->
<div class="hero">
<p class="hero-num">{stats.totalDays}</p>
<p class="hero-label">days of travel</p>
</div>
<!-- Stat grid -->
<div class="stat-grid">
<div class="stat-box">
<p class="stat-num">{stats.countries.length}</p>
<p class="stat-desc">countries</p>
</div>
<div class="stat-box">
<p class="stat-num">{stats.cities.length}</p>
<p class="stat-desc">cities</p>
</div>
<div class="stat-box">
<p class="stat-num">{stats.flightHrs}h</p>
<p class="stat-desc">in the air</p>
</div>
<div class="stat-box">
<p class="stat-num">{Object.keys(stats.contDays).length}</p>
<p class="stat-desc">continents</p>
</div>
</div>
<!-- Fun facts -->
<div class="facts">
{#if stats.topContinent}
<div class="fact">
<span class="fact-icon">🌏</span>
<span class="fact-text">Spent <strong>{stats.topContinent[1]} days</strong> in {stats.topContinent[0]}</span>
</div>
{/if}
{#if stats.longest}
<div class="fact">
<span class="fact-icon">📍</span>
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.city}</span>
</div>
{/if}
{#if stats.flightHrs > 0}
<div class="fact">
<span class="fact-icon">✈️</span>
<span class="fact-text">~<strong>{stats.flightHrs} hrs</strong> crossing skies</span>
</div>
{/if}
{#if stats.favCountry && stats.favCountry[1] > 1}
<div class="fact">
<span class="fact-icon">❤️</span>
<span class="fact-text">Kept coming back to <strong>{stats.favCountry[0]}</strong></span>
</div>
{/if}
<div class="fact">
<span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</span>
<span class="fact-text">{stats.soloCount} solo · {stats.friendCount} with friends</span>
</div>
</div>
<!-- Continent bar -->
{#if Object.keys(stats.contDays).length > 0}
<div class="cont-section">
<div class="cont-bar">
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
<div class="cont-seg" style="flex:{days}; background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"
title="{cont}: {days}d"></div>
{/each}
</div>
<div class="cont-legend">
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
<span class="cont-item">
<span class="cont-dot" style="background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"></span>
{cont} {days}d
</span>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
<div class="card-footer">
<span>mapjournal.app</span>
</div>
</div>
</div>
{/if}
</div>
</div>
<style>
/* ── Overlay ── */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
backdrop-filter: blur(4px);
}
.overlay-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
height: 100%;
overflow-y: auto;
}
/* ── Controls ── */
.controls {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.ctrl-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--sans);
font-size: 13px;
font-weight: 400;
padding: 8px 18px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
.ctrl-btn:hover { background: rgba(255,255,255,0.18); }
.ctrl-btn--primary {
background: #7c3aed;
border-color: #7c3aed;
}
.ctrl-btn--primary:hover { background: #6d28d9; }
.ctrl-btn:disabled { opacity: 0.6; cursor: default; }
/* ── Card wrap (scrollable preview) ── */
.card-wrap {
flex-shrink: 0;
}
/* ── Card itself (Instagram story 9:16 → 360×640 preview) ── */
.card {
width: 360px;
min-height: 640px;
border-radius: 20px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
padding: 32px 28px 24px;
gap: 0;
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
background: #0f0a1e;
color: #fff;
/* continent color vars */
--cont-asia: #f87171;
--cont-europe: #818cf8;
--cont-africa: #fb923c;
--cont-namerica: #4ade80;
--cont-n-america: #4ade80;
--cont-samerica: #fbbf24;
--cont-s-america: #fbbf24;
--cont-oceania: #c084fc;
}
/* Backgrounds */
.bg-gradient {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 40% at 80% 10%, rgba(124,58,237,0.35) 0%, transparent 70%),
radial-gradient(ellipse 50% 50% at 10% 80%, rgba(99,102,241,0.25) 0%, transparent 60%);
pointer-events: none;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
}
/* Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 36px;
position: relative;
z-index: 1;
}
.card-brand {
font-size: 9px;
font-weight: 500;
letter-spacing: 0.22em;
color: #a5b4fc;
}
.card-year {
font-size: 11px;
font-weight: 300;
color: rgba(255,255,255,0.4);
letter-spacing: 0.06em;
}
/* Hero */
.hero {
position: relative;
z-index: 1;
margin-bottom: 28px;
}
.hero-num {
font-size: 88px;
font-weight: 400;
line-height: 1;
letter-spacing: -4px;
color: #fff;
}
.hero-label {
font-size: 16px;
font-weight: 300;
color: #a5b4fc;
letter-spacing: 0.04em;
margin-top: 4px;
}
/* Stat grid */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.stat-box {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 10px 8px;
text-align: center;
}
.stat-num {
font-size: 22px;
font-weight: 400;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
margin-bottom: 4px;
}
.stat-desc {
font-size: 9px;
font-weight: 400;
color: rgba(255,255,255,0.4);
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Facts */
.facts {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.fact {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
padding: 10px 14px;
}
.fact-icon { font-size: 16px; flex-shrink: 0; }
.fact-text {
font-size: 12px;
font-weight: 300;
color: rgba(255,255,255,0.7);
line-height: 1.4;
}
.fact-text strong { color: #fff; font-weight: 400; }
/* Continent bar */
.cont-section {
position: relative;
z-index: 1;
margin-bottom: 20px;
}
.cont-bar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
gap: 2px;
margin-bottom: 8px;
}
.cont-seg { border-radius: 3px; min-width: 4px; }
.cont-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
}
.cont-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 9px;
font-weight: 300;
color: rgba(255,255,255,0.5);
letter-spacing: 0.04em;
}
.cont-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
/* Footer */
.card-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid rgba(255,255,255,0.08);
font-size: 9px;
font-weight: 300;
color: rgba(255,255,255,0.25);
letter-spacing: 0.12em;
text-transform: uppercase;
position: relative;
z-index: 1;
}
</style>

View File

@@ -13,6 +13,16 @@
let mainPhoto = $derived(entry.photos[0] ?? null);
let thumbPhotos = $derived(entry.photos.slice(1, 4));
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
const transportIcons = {
flight: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.18 10a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.09 0h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 8a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 15z"/></svg>`,
train: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="16" rx="2"/><path d="M4 12h16M8 20l-2 2M16 20l2 2M12 12v6"/><circle cx="8.5" cy="7.5" r="1"/><circle cx="15.5" cy="7.5" r="1"/></svg>`,
bus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6v6M15 6v6M2 12h19.6M18 18h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2m0 0v2m8-2v2M6 18h12"/><circle cx="8" cy="18" r="2"/><circle cx="16" cy="18" r="2"/></svg>`,
car: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v9a2 2 0 0 1-2 2h-2"/><circle cx="7.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></svg>`,
ship: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1 .6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-9-4-9 4c0 2.9.94 5.34 2.81 7.76"/><path d="M19 13V7a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v6"/><path d="M12 10v4"/><path d="M12 3v4"/></svg>`,
walk: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4" r="2"/><path d="m9 22 1-7-2.5-2.5L9 9l6 1-1 4.5L16 22"/><path d="M6.5 11.5 4 12l2 6"/><path d="M17.5 11.5 20 12l-2 6"/></svg>`,
};
let transportLabel = $derived({ flight: 'Flight', train: 'Train', bus: 'Bus', car: 'Car', ship: 'Ship', walk: 'Walk' }[entry.transport] ?? '');
</script>
<li class="v-item">
@@ -91,6 +101,13 @@
<div class="card-info">
<span class="city">{entry.location.city}</span>
<div class="meta">
{#if entry.transport}
<span class="transport-chip transport-chip--{entry.transport}">
{@html transportIcons[entry.transport] ?? ''}
{transportLabel}
</span>
<span class="dot-sep">·</span>
{/if}
<span>{formatDate(entry.date)}</span>
<span class="dot-sep">·</span>
<span>{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
@@ -298,4 +315,23 @@
flex-shrink: 0;
}
.dot-sep { color: var(--border-bright); }
.transport-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 400;
padding: 2px 7px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-sub);
}
.transport-chip--flight { color: #7c3aed; background: rgba(124,58,237,0.07); border-color: rgba(124,58,237,0.2); }
.transport-chip--train { color: #0369a1; background: rgba(3,105,161,0.07); border-color: rgba(3,105,161,0.2); }
.transport-chip--bus { color: #15803d; background: rgba(21,128,61,0.07); border-color: rgba(21,128,61,0.2); }
.transport-chip--car { color: #b45309; background: rgba(180,83,9,0.07); border-color: rgba(180,83,9,0.2); }
.transport-chip--ship { color: #0e7490; background: rgba(14,116,144,0.07); border-color: rgba(14,116,144,0.2); }
.transport-chip--walk { color: #65a30d; background: rgba(101,163,13,0.07); border-color: rgba(101,163,13,0.2); }
</style>

View File

@@ -4,16 +4,19 @@
import TimelineToolbar from './TimelineToolbar.svelte';
import TimelineCard from './TimelineCard.svelte';
import JournalDetail from './JournalDetail.svelte';
import JournalSummary from './JournalSummary.svelte';
import EditForm from './EditForm.svelte';
import ShareCard from './ShareCard.svelte';
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null));
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
let showShare = $state(false);
let newEntryCountry = $state('');
// When App passes a country from the map, open new-entry form automatically
// When App passes a country from the map, capture it locally before clearing
$effect(() => {
if (pendingCountry) {
newEntryCountry = pendingCountry;
selectedId = null;
view = 'new';
onNewEntryClear();
@@ -50,7 +53,7 @@
{#if view === 'new'}
<div class="detail-scroll">
<EditForm initialCountry={pendingCountry} onBack={() => { view = 'list'; onDetailChange(false); }} />
<EditForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} />
</div>
{:else if view === 'edit' && selected}
<div class="detail-scroll">
@@ -77,10 +80,23 @@
</button>
</div>
<JournalSummary entries={sortedEntries} />
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length > 0}
<button class="share-nudge" onclick={() => (showShare = true)}>
<span class="nudge-left">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
</svg>
Share your journey
</span>
<span class="nudge-right">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'} · save as PNG →
</span>
</button>
{/if}
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}
@@ -114,6 +130,10 @@
</div>
{#if showShare}
<ShareCard entries={sortedEntries} onClose={() => (showShare = false)} />
{/if}
<style>
.journal-page {
flex: 1;
@@ -272,4 +292,39 @@
flex-shrink: 0;
}
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.share-nudge {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 10px;
border: 1px dashed var(--border-bright);
background: var(--bg-subtle);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: var(--sans);
text-align: left;
}
.share-nudge:hover {
border-color: var(--accent-light);
background: var(--accent-bg);
}
.nudge-left {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 400;
color: var(--text-h);
}
.nudge-right {
font-size: 11px;
font-weight: 300;
color: var(--text-sub);
letter-spacing: 0.02em;
}
.share-nudge:hover .nudge-right { color: var(--accent); }
</style>