diff --git a/package-lock.json b/package-lock.json
index 4c7fce0..f5205b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index c363719..9e07327 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/src/lib/stores/journalStore.js b/src/lib/stores/journalStore.js
index fe2c673..57b29e6 100644
--- a/src/lib/stores/journalStore.js
+++ b/src/lib/stores/journalStore.js
@@ -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.',
diff --git a/src/lib/timeline/EditForm.svelte b/src/lib/timeline/EditForm.svelte
index 76e11ea..c4d942a 100644
--- a/src/lib/timeline/EditForm.svelte
+++ b/src/lib/timeline/EditForm.svelte
@@ -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 @@
+
+
+
+ {#each transportOptions as opt}
+
+ {/each}
+
+
+
(photos = p)} />
@@ -136,16 +156,6 @@
-
@@ -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);
+ }
+
diff --git a/src/lib/timeline/JournalDetail.svelte b/src/lib/timeline/JournalDetail.svelte
index 6508741..e8dcc58 100644
--- a/src/lib/timeline/JournalDetail.svelte
+++ b/src/lib/timeline/JournalDetail.svelte
@@ -125,23 +125,6 @@
{entry.memo}
-
-
Trip soundtrack
-
-
-
-
{entry.song.title}
-
{entry.song.artist}
-
-
-
-
@@ -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;
diff --git a/src/lib/timeline/ShareCard.svelte b/src/lib/timeline/ShareCard.svelte
new file mode 100644
index 0000000..1fc1b16
--- /dev/null
+++ b/src/lib/timeline/ShareCard.svelte
@@ -0,0 +1,483 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if stats}
+
+
+
+
+
+
+
+
+
+
+
+
+
{stats.totalDays}
+
days of travel
+
+
+
+
+
+
{stats.countries.length}
+
countries
+
+
+
{stats.cities.length}
+
cities
+
+
+
{stats.flightHrs}h
+
in the air
+
+
+
{Object.keys(stats.contDays).length}
+
continents
+
+
+
+
+
+ {#if stats.topContinent}
+
+ 🌏
+ Spent {stats.topContinent[1]} days in {stats.topContinent[0]}
+
+ {/if}
+ {#if stats.longest}
+
+ 📍
+ Longest stay: {stats.longest.days} days in {stats.longest.location.city}
+
+ {/if}
+ {#if stats.flightHrs > 0}
+
+ ✈️
+ ~{stats.flightHrs} hrs crossing skies
+
+ {/if}
+ {#if stats.favCountry && stats.favCountry[1] > 1}
+
+ ❤️
+ Kept coming back to {stats.favCountry[0]}
+
+ {/if}
+
+ {stats.soloCount >= stats.friendCount ? '🧳' : '👥'}
+ {stats.soloCount} solo · {stats.friendCount} with friends
+
+
+
+
+ {#if Object.keys(stats.contDays).length > 0}
+
+
+ {#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
+
+ {/each}
+
+
+ {#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
+
+
+ {cont} {days}d
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/src/lib/timeline/TimelineCard.svelte b/src/lib/timeline/TimelineCard.svelte
index 166e5fc..7e8b113 100644
--- a/src/lib/timeline/TimelineCard.svelte
+++ b/src/lib/timeline/TimelineCard.svelte
@@ -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: ``,
+ train: ``,
+ bus: ``,
+ car: ``,
+ ship: ``,
+ walk: ``,
+ };
+ let transportLabel = $derived({ flight: 'Flight', train: 'Train', bus: 'Bus', car: 'Car', ship: 'Ship', walk: 'Walk' }[entry.transport] ?? '');
@@ -91,6 +101,13 @@
{entry.location.city}
+ {#if entry.transport}
+
+ {@html transportIcons[entry.transport] ?? ''}
+ {transportLabel}
+
+
·
+ {/if}
{formatDate(entry.date)}
·
{entry.days} {entry.days === 1 ? 'day' : 'days'}
@@ -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); }
diff --git a/src/lib/timeline/TimelineView.svelte b/src/lib/timeline/TimelineView.svelte
index 5e79106..a6c8d7f 100644
--- a/src/lib/timeline/TimelineView.svelte
+++ b/src/lib/timeline/TimelineView.svelte
@@ -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'}
- { view = 'list'; onDetailChange(false); }} />
+ { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} />
{:else if view === 'edit' && selected}
@@ -77,10 +80,23 @@
-
-
(sortKey = k)} />
+ {#if sortedEntries.length > 0}
+
+ {/if}
+
{#if sortedEntries.length === 0}
No journal entries yet.
{:else}
@@ -114,6 +130,10 @@
+{#if showShare}
+
(showShare = false)} />
+{/if}
+