29 Commits

Author SHA1 Message Date
1743e7fcbe added saving photos to firebase 2026-06-16 23:18:20 +09:00
d614ddb322 fix: UI world map 2026-06-16 22:31:17 +09:00
Haeri Kim
ed415a78a1 added default images for card ui & fixed ui 2026-06-16 21:55:29 +09:00
Haeri Kim
9109d6a861 feat: 3-step edit form, share card redesign, map fixes
- EditForm: refactored to 3-step flow matching NewEntryForm (details → photos → memo), transport icons with bigger images
- ShareCard: profile image, "You've colored X% of the world map" hero stat, removed stat boxes and continent bar, font embedding fix for PNG export, renamed brand to Journi
- JourneyView: auto-close after journey complete
- WorldMap: removed home marker icon, fix home marker reposition on resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:38:29 +09:00
Haeri Kim
5a95fccd70 fix: use logo-signin, consolidate plane asset, fix home marker on resize
- Sign-in page now uses logo-signin.png
- World map animation uses airplane.png; remove unused animationPlane.png
- Re-project home marker on map resize so it stays correct when stats panel collapses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:01:26 +09:00
8d36c3faca after merge fix 2026-06-16 20:14:45 +09:00
haerikimmm
b518016a21 fix: home country always purple regardless of visited status 2026-06-16 19:43:54 +09:00
haerikimmm
665472b281 fix: color home country purple on world map 2026-06-16 19:42:42 +09:00
haerikimmm
2226a483c5 chore: reorganize timeline into view/ and detail/ folders, move home.png to assets/ 2026-06-16 19:41:08 +09:00
haerikimmm
93636b6968 chore: remove unused duplicate files 2026-06-16 19:38:38 +09:00
haerikimmm
5718bca963 fix: constrain timeline content width with side margins 2026-06-16 19:34:49 +09:00
haerikimmm
6f41f6e53e fix: proper 2-column layout with scroll, header above columns, side padding 2026-06-16 19:33:50 +09:00
haerikimmm
d157055ab7 fix: journal tab name, 2-column layout with SharePreview on right 2026-06-16 19:32:34 +09:00
haerikimmm
76d7e815c3 style: question-form labels for trip detail fields 2026-06-16 19:26:05 +09:00
haerikimmm
c7cf053105 feat: static city suggestions by country, fix city dropdown, coloring animation on save 2026-06-16 19:21:26 +09:00
haerikimmm
a7079c1f18 feat: add home marker on map, crayon cursor in worldmap, remove logo from topbar 2026-06-16 19:01:59 +09:00
haerikimmm
cf9717149f style home country picker page with home image and app theme 2026-06-16 18:40:51 +09:00
haerikimmm
ec4eea0977 fix save journal button and reactive city filtering by country 2026-06-16 18:32:48 +09:00
haerikimmm
92fae28383 merge globe 3D view with transport icons and real journal data 2026-06-16 17:49:07 +09:00
haerikimmm
b3c5fbe3dd fix: add journalStore shim and sync journals writable store with entriesStore 2026-06-16 17:42:48 +09:00
haerikimmm
8e9b40cc69 merge remote main: keep our journal-driven selection and transport animation 2026-06-16 17:32:11 +09:00
haerikimmm
d389b496b4 merge feature/timeline into main 2026-06-16 17:27:19 +09:00
haerikimmm
bf2700efb7 add animated logo and slogan to login page 2026-06-16 17:26:18 +09:00
0a823948df updated comunication with firebase 2026-06-16 16:18:28 +09:00
36f0c25721 layout update 2026-06-16 15:07:16 +09:00
d09946161f Merge branch 'feature/timeline' 2026-06-16 13:14:49 +09:00
87993ae9c6 updated animation 2026-06-16 12:36:37 +09:00
65248fd082 updated adding jurney 2026-06-16 01:39:59 +09:00
e9662754c4 add globe animation 2026-06-15 23:25:58 +09:00
58 changed files with 2114 additions and 2779 deletions

View File

@@ -3,7 +3,7 @@
"configurations": [
{
"name": "Map-Jurnal",
"cwd": "/Users/haerikim/Desktop/SP Map Journal/Map-Jurnal",
"cwd": ".",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,

5
.firebaserc Normal file
View File

@@ -0,0 +1,5 @@
{
"projects": {
"default": "map-jurnal"
}
}

7
cors.json Normal file
View File

@@ -0,0 +1,7 @@
[
{
"origin": ["http://localhost:5173", "https://map-jurnal.web.app", "https://map-jurnal.firebaseapp.com"],
"method": ["GET", "HEAD", "PUT", "POST", "DELETE"],
"maxAgeSeconds": 3600
}
]

5
firebase.json Normal file
View File

@@ -0,0 +1,5 @@
{
"storage": {
"rules": "storage.rules"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

11
scripts/get-token.mjs Normal file
View File

@@ -0,0 +1,11 @@
import { getAccessToken } from 'firebase-tools';
import { writeFileSync } from 'fs';
(async () => {
try {
const token = await getAccessToken();
writeFileSync('token.txt', token);
console.log('Token saved');
} catch (e) {
console.error('Failed:', e.message);
}
})();

36
scripts/set-cors.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { readFileSync } from 'fs';
const config = JSON.parse(readFileSync(
'C:\\Users\\tomas\\.config\\configstore\\firebase-tools.json', 'utf-8'
));
const token = config.tokens.access_token;
// Try to list buckets first
const listRes = await fetch('https://storage.googleapis.com/storage/v1/b?project=map-jurnal', {
headers: { Authorization: `Bearer ${token}` },
});
const buckets = await listRes.json();
console.log('Buckets:', buckets.items?.map(b => b.name) ?? buckets);
const bucket = 'map-jurnal.appspot.com';
const corsConfig = [{
origin: ['http://localhost:5173', 'https://map-jurnal.web.app', 'https://map-jurnal.firebaseapp.com'],
method: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'],
maxAgeSeconds: 3600,
}];
const res = await fetch(`https://storage.googleapis.com/storage/v1/b/${bucket}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ cors: corsConfig }),
});
if (res.ok) {
console.log('CORS configured successfully!');
} else {
const text = await res.text();
console.error('Failed:', res.status, text);
}

32
scripts/set-cors2.mjs Normal file
View File

@@ -0,0 +1,32 @@
import { readFileSync } from 'fs';
import { Storage } from '@google-cloud/storage';
const config = JSON.parse(readFileSync('C:/Users/tomas/.config/configstore/firebase-tools.json', 'utf-8'));
const storage = new Storage({
projectId: 'map-jurnal',
credentials: {
client_email: 'firebase-cli@map-jurnal.iam.gserviceaccount.com',
// We'll use token-based auth instead
},
});
// Try with direct token
const token = config.tokens.access_token;
// List all buckets to find the right name
const [buckets] = await storage.getBuckets({ project: 'map-jurnal' });
console.log('Buckets:', buckets.map(b => b.name));
if (buckets.length > 0) {
const bucket = buckets[0];
console.log('Configuring CORS for:', bucket.name);
await bucket.setCorsConfiguration([
{
origin: ['http://localhost:5173', 'https://map-jurnal.web.app', 'https://map-jurnal.firebaseapp.com'],
method: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'],
maxAgeSeconds: 3600,
},
]);
console.log('CORS configured!');
}

View File

@@ -6,13 +6,14 @@
import WorldMap from './lib/world-map/WorldMap.svelte';
import JourneyView from './lib/world-map/JourneyView.svelte';
import StatsPanel from './lib/world-map/StatsPanel.svelte';
import TimelineView from './lib/timeline/TimelineView.svelte';
import TimelineView from './lib/timeline/view/TimelineView.svelte';
let screen = $state('worldmap');
let journeyActive = $state(false);
let journeyProgress = $state(null);
let inDetail = $state(false);
let pendingCountry = $state('');
let journeyMode = $state('map');
function onNavigate(s) {
screen = s;
@@ -56,10 +57,10 @@
<div class="worldmap-page">
<div class="map-area">
{#if journeyActive}
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} />
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} mode={journeyMode} onmodechange={(m) => journeyMode = m} />
{:else}
<WorldMap onCountryClick={handleCountryClick} />
<button class="journey-play-btn" onclick={startJourney}></button>
<button class="journey-play-btn" onclick={startJourney}> Replay My Trips</button>
{/if}
</div>
{#if !journeyActive}<StatsPanel />{/if}
@@ -116,14 +117,14 @@
bottom: 24px;
right: 24px;
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
padding: 12px 28px;
border-radius: 24px;
border: none;
background: #8b5cf6;
color: #fff;
font-size: 20px;
line-height: 1;
font-size: 15px;
font-weight: 500;
gap: 6px;
cursor: pointer;
display: flex;
align-items: center;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/default-1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

BIN
src/assets/default-2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

BIN
src/assets/default-3.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

BIN
src/assets/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/logo-cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src/assets/logo-signin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 112 KiB

BIN
src/assets/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View File

@@ -1,93 +1,72 @@
<script>
import { getUser, getUserProfile, setHomeCountry } from './userStore.svelte.js';
import worldData from 'world-atlas/countries-50m.json';
import { countryNames } from '../shared/countries.js';
import homeImg from '../../assets/home.png';
let user = $derived(getUser());
let profile = $derived(getUserProfile());
const countries = $derived.by(() => {
if (!worldData?.objects?.countries?.geometries) return [];
return worldData.objects.countries.geometries
.map(g => ({ name: g.properties?.name, code: g.id }))
.filter(c => c.name && c.code)
.sort((a, b) => a.name.localeCompare(b.name));
});
let search = $state('');
let selectedCountry = $state(null);
let filtered = $derived(
search
? countries.filter(c => c.name.toLowerCase().includes(search.toLowerCase()))
: countries
);
let selectedCountry = $state('');
let open = $state(false);
function select(c) {
selectedCountry = c;
search = c.name;
let filtered = $derived(
search.trim()
? countryNames.filter(c => c.toLowerCase().includes(search.toLowerCase()))
: countryNames
);
function select(name) {
selectedCountry = name;
search = name;
open = false;
}
function handleSubmit() {
if (selectedCountry) {
setHomeCountry(selectedCountry.name, selectedCountry.code);
setHomeCountry(selectedCountry, selectedCountry);
}
}
function handleKeydown(e) {
if (e.key === 'Enter' && selectedCountry) {
handleSubmit();
}
if (e.key === 'Escape') {
open = false;
}
if (e.key === 'Enter' && selectedCountry) handleSubmit();
if (e.key === 'Escape') open = false;
}
</script>
<div class="overlay">
<div class="card">
<h1 class="heading">Welcome, {profile?.displayName || 'Traveler'}!</h1>
<p class="subtitle">Select your home country to get started</p>
<img src={homeImg} alt="home" class="home-img" />
<h1 class="title">Welcome, {profile?.displayName?.split(' ')[0] || 'Traveler'}!</h1>
<p class="subtitle">Where do you call home?</p>
<div class="dropdown" class:open>
<div class="dropdown">
<input
type="text"
placeholder="Search for a country..."
placeholder="Search country..."
bind:value={search}
onfocus={() => { open = true; }}
oninput={() => { open = true; selectedCountry = null; }}
oninput={() => { open = true; selectedCountry = ''; }}
onkeydown={handleKeydown}
class="search-input"
/>
{#if open}
{#if open && filtered.length > 0}
<ul class="list" role="listbox">
{#each filtered as country}
{#each filtered as name}
<li
role="option"
aria-selected={selectedCountry?.name === country.name}
class:selected={selectedCountry?.name === country.name}
onclick={() => select(country)}
onkeydown={(e) => { if (e.key === 'Enter') select(country); }}
aria-selected={selectedCountry === name}
class:selected={selectedCountry === name}
onmousedown={() => select(name)}
tabindex="0"
>
{country.name}
</li>
>{name}</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">No countries found</li>
{/if}
</ul>
{/if}
</div>
<button
class="continue-btn"
disabled={!selectedCountry}
onclick={handleSubmit}
>
Continue
<button class="continue-btn" disabled={!selectedCountry} onclick={handleSubmit}>
Set home country
</button>
</div>
</div>
@@ -96,113 +75,116 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.85);
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(4px);
padding-bottom: 20vh;
}
.card {
background: #1e2937;
border-radius: 16px;
padding: 40px 36px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
max-width: 420px;
max-width: 360px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.heading {
font: 700 24px/1.3 sans-serif;
color: #f1f5f9;
margin-bottom: 6px;
.home-img {
width: 200px;
height: 200px;
object-fit: contain;
margin-bottom: 16px;
}
.title {
font-family: var(--heading);
font-size: 24px;
font-weight: 600;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0 0 6px;
}
.subtitle {
font: 400 15px/1.4 sans-serif;
color: #94a3b8;
margin-bottom: 28px;
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text);
margin: 0 0 24px;
}
.dropdown {
position: relative;
margin-bottom: 24px;
width: 100%;
margin-bottom: 16px;
text-align: left;
}
.search-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #475569;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: #0f172a;
color: #f1f5f9;
font: 400 15px/1.4 sans-serif;
background: var(--bg-subtle);
color: var(--text-h);
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #3b82f6;
}
.search-input::placeholder {
color: #64748b;
transition: border-color 0.15s;
box-sizing: border-box;
}
.search-input:focus { border-color: var(--accent-border); }
.search-input::placeholder { color: var(--text-sub); }
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
max-height: 220px;
overflow-y: auto;
background: #0f172a;
border: 1px solid #475569;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
list-style: none;
z-index: 10;
padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.list li {
padding: 10px 16px;
padding: 8px 12px;
cursor: pointer;
color: #cbd5e1;
font: 400 14px/1.4 sans-serif;
transition: background 0.15s;
color: var(--text);
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
border-radius: 6px;
transition: background 0.1s;
}
.list li:hover,
.list li.selected {
background: #1e3a5f;
color: #f1f5f9;
}
.no-results {
color: #64748b;
cursor: default;
.list li:hover, .list li.selected {
background: var(--accent-bg);
color: var(--accent);
}
.continue-btn {
width: 100%;
padding: 12px 24px;
border: none;
padding: 11px 24px;
border: 1px solid var(--border);
border-radius: 8px;
background: #3b82f6;
background: var(--accent);
color: #fff;
font: 600 16px/1.4 sans-serif;
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.continue-btn:hover:not(:disabled) {
background: #2563eb;
}
.continue-btn:disabled {
opacity: 0.4;
cursor: default;
transition: background 0.15s, opacity 0.15s;
}
.continue-btn:hover:not(:disabled) { background: var(--accent-dark); }
.continue-btn:disabled { opacity: 0.4; cursor: default; }
</style>

View File

@@ -1,6 +1,6 @@
<script>
import { signInWithGoogle } from './userStore.svelte.js';
import logoImg from '../../assets/logo.png';
import logoImg from '../../assets/logo-signin.png';
</script>
<div class="overlay">
@@ -29,6 +29,7 @@
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 20vh;
z-index: 100;
}
@@ -47,6 +48,24 @@
height: 216px;
object-fit: contain;
margin-bottom: 16px;
animation: jitter 1.4s steps(1, end) 1 forwards;
}
@keyframes jitter {
0% { transform: scale(0.7) rotate(0deg); opacity: 0.5; }
8% { transform: scale(0.85) rotate(-16deg); opacity: 1; }
16% { transform: scale(1.0) rotate(16deg); }
24% { transform: scale(1.06) rotate(-16deg); }
32% { transform: scale(1.12) rotate(16deg); }
40% { transform: scale(1.16) rotate(-16deg); }
48% { transform: scale(1.2) rotate(16deg); }
56% { transform: scale(1.2) rotate(-16deg); }
64% { transform: scale(1.2) rotate(16deg); }
72% { transform: scale(1.2) rotate(-10deg); }
80% { transform: scale(1.2) rotate(10deg); }
88% { transform: scale(1.2) rotate(-4deg); }
94% { transform: scale(1.2) rotate(4deg); }
100% { transform: scale(1.2) rotate(0deg); }
}
.title {

View File

@@ -1,19 +0,0 @@
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);
export const googleProvider = new GoogleAuthProvider();

View File

@@ -1,7 +1,5 @@
<script>
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
import logo1Img from '../../assets/logo-1.png';
let { screen, onNavigate } = $props();
let user = $derived(getUser());
@@ -22,7 +20,6 @@
<div class="topbar">
<div class="left">
<div class="brand">
<img src={logo1Img} class="logo-img" alt="Journi logo" />
<span class="app-name">Journi</span>
</div>
</div>
@@ -33,8 +30,8 @@
class="slider"
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
></div>
<button onclick={() => onNavigate('worldmap')}>Worldmap</button>
<button onclick={() => onNavigate('timeline')}>Timeline</button>
<button class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Worldmap</button>
<button class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button>
</div>
</div>
@@ -93,12 +90,6 @@
gap: 4px;
}
.logo-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.app-name {
font-family: var(--heading);
font-size: 22px;
@@ -119,18 +110,18 @@
display: flex;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
border-radius: 9999px;
padding: 4px;
}
.slider {
position: absolute;
top: 3px;
left: 3px;
width: calc(50% - 3px);
height: calc(100% - 6px);
background: var(--bg);
border-radius: 6px;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--accent);
border-radius: 9999px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
transition: transform 0.25s ease;
pointer-events: none;
@@ -140,15 +131,20 @@
position: relative;
z-index: 1;
flex: 1;
padding: 4px 18px;
padding: 6px 24px;
border: none;
background: none;
cursor: pointer;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
font-size: 14px;
font-weight: 500;
color: var(--text);
letter-spacing: 0.01em;
transition: color 0.2s ease;
}
.segmented button.active {
color: #fff;
}
.right {
display: flex;

View File

@@ -1,16 +1,22 @@
import { journals } from '../stores/journalStore.js';
import { nameToId } from '../shared/countries.js';
import { getUserProfile } from '../auth/userStore.svelte.js';
let selected = $state(new Set());
let totalCountries = $state(0);
let flashing = $state(new Set());
// Derive visited countries from journal entries
journals.subscribe((entries) => {
const ids = new Set();
for (const e of entries) {
const id = nameToId[e.location?.country];
if (id) ids.add(id);
}
const profile = getUserProfile();
if (profile?.homeCountry) {
const homeId = nameToId[profile.homeCountry];
if (homeId) ids.add(homeId);
}
selected = ids;
});
@@ -25,3 +31,16 @@ export function setTotalCount(n) {
export function getTotalCount() {
return totalCountries;
}
export function getFlashing() {
return flashing;
}
export function flashCountry(countryName) {
const id = nameToId[countryName];
if (!id) return;
flashing = new Set([...flashing, id]);
setTimeout(() => {
flashing = new Set([...flashing].filter(x => x !== id));
}, 1600);
}

209
src/lib/shared/cities.js Normal file
View File

@@ -0,0 +1,209 @@
export const ALL_CITIES = {
'Afghanistan': ['Kabul', 'Herat', 'Kandahar', 'Mazar-i-Sharif', 'Jalalabad'],
'Albania': ['Tirana', 'Durrës', 'Vlorë', 'Shkodër', 'Sarandë'],
'Algeria': ['Algiers', 'Oran', 'Constantine', 'Annaba', 'Tlemcen'],
'Angola': ['Luanda', 'Huambo', 'Benguela', 'Lubango', 'Malanje'],
'Argentina': ['Buenos Aires', 'Córdoba', 'Rosario', 'Mendoza', 'Bariloche', 'Salta', 'Ushuaia', 'Mar del Plata', 'Iguazú'],
'Armenia': ['Yerevan', 'Gyumri', 'Vanadzor', 'Vagharshapat'],
'Australia': ['Sydney', 'Melbourne', 'Brisbane', 'Perth', 'Adelaide', 'Gold Coast', 'Cairns', 'Hobart', 'Darwin', 'Canberra', 'Newcastle'],
'Austria': ['Vienna', 'Salzburg', 'Innsbruck', 'Graz', 'Linz', 'Hallstatt', 'Zell am See'],
'Azerbaijan': ['Baku', 'Ganja', 'Sumqayit', 'Mingachevir', 'Nakhchivan'],
'Bahamas': ['Nassau', 'Freeport', 'Marsh Harbour', 'George Town'],
'Bahrain': ['Manama', 'Muharraq', 'Riffa', 'Hamad Town'],
'Bangladesh': ['Dhaka', 'Chittagong', 'Sylhet', 'Cox\'s Bazar', 'Rajshahi', 'Khulna'],
'Barbados': ['Bridgetown', 'Speightstown', 'Oistins', 'Holetown'],
'Belarus': ['Minsk', 'Brest', 'Grodno', 'Vitebsk', 'Gomel'],
'Belgium': ['Brussels', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Liège', 'Namur'],
'Belize': ['Belize City', 'San Ignacio', 'Belmopan', 'Placencia', 'Caye Caulker'],
'Benin': ['Porto-Novo', 'Cotonou', 'Parakou', 'Abomey'],
'Bhutan': ['Thimphu', 'Paro', 'Punakha', 'Jakar'],
'Bolivia': ['La Paz', 'Sucre', 'Santa Cruz', 'Cochabamba', 'Uyuni', 'Potosí'],
'Bosnia and Herz.': ['Sarajevo', 'Mostar', 'Banja Luka', 'Tuzla', 'Zenica'],
'Botswana': ['Gaborone', 'Maun', 'Kasane', 'Francistown'],
'Brazil': ['Rio de Janeiro', 'São Paulo', 'Brasília', 'Salvador', 'Fortaleza', 'Recife', 'Porto Alegre', 'Curitiba', 'Manaus', 'Florianópolis', 'Belo Horizonte', 'Iguaçu Falls', 'Paraty', 'Bonito'],
'Brunei': ['Bandar Seri Begawan', 'Kuala Belait', 'Seria', 'Tutong'],
'Bulgaria': ['Sofia', 'Plovdiv', 'Varna', 'Burgas', 'Ruse', 'Bansko'],
'Burkina Faso': ['Ouagadougou', 'Bobo-Dioulasso', 'Koudougou', 'Banfora'],
'Burundi': ['Bujumbura', 'Gitega', 'Ngozi', 'Ruyigi'],
'Cabo Verde': ['Praia', 'Mindelo', 'Santa Maria', 'Sal Rei'],
'Cambodia': ['Phnom Penh', 'Siem Reap', 'Sihanoukville', 'Battambang', 'Kampot'],
'Cameroon': ['Yaoundé', 'Douala', 'Bamenda', 'Garoua', 'Kribi'],
'Canada': ['Toronto', 'Vancouver', 'Montreal', 'Calgary', 'Ottawa', 'Quebec City', 'Halifax', 'Whistler', 'Banff', 'Victoria', 'Edmonton'],
'Chad': ['N\'Djamena', 'Moundou', 'Sarh', 'Abéché'],
'Chile': ['Santiago', 'Valparaíso', 'Viña del Mar', 'Puerto Varas', 'San Pedro de Atacama', 'Punta Arenas', 'Easter Island (Rapa Nui)', 'Concepción', 'La Serena'],
'China': ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Chengdu', 'Hangzhou', 'Xi\'an', 'Kunming', 'Zhangjiajie', 'Guilin', 'Hong Kong', 'Macao', 'Lhasa', 'Suzhou', 'Nanjing', 'Chongqing', 'Wuhan', 'Harbin'],
'Colombia': ['Bogotá', 'Medellín', 'Cartagena', 'Cali', 'Santa Marta', 'Bucaramanga', 'San Andrés', 'Leticia', 'Tayrona'],
'Congo': ['Brazzaville', 'Pointe-Noire', 'Dolisie', 'Ouésso'],
'Costa Rica': ['San José', 'Liberia', 'Puerto Viejo', 'La Fortuna', 'Monteverde', 'Manuel Antonio', 'Tamarindo'],
'Croatia': ['Zagreb', 'Dubrovnik', 'Split', 'Zadar', 'Rovinj', 'Pula', 'Hvar', 'Šibenik', 'Trogir'],
'Cuba': ['Havana', 'Varadero', 'Trinidad', 'Viñales', 'Santiago de Cuba', 'Cienfuegos'],
'Curaçao': ['Willemstad', 'Westpunt', 'Sint Willibrordus'],
'Cyprus': ['Nicosia', 'Limassol', 'Paphos', 'Larnaca', 'Ayia Napa'],
'Czechia': ['Prague', 'Brno', 'Český Krumlov', 'Karlovy Vary', 'Plzeň', 'Olomouc', 'Ostrava', 'Liberec'],
'Dem. Rep. Congo': ['Kinshasa', 'Lubumbashi', 'Goma', 'Bukavu', 'Kisangani'],
'Denmark': ['Copenhagen', 'Aarhus', 'Odense', 'Aalborg', 'Ribe', 'Skagen', 'Bornholm', 'Møns Klint'],
'Djibouti': ['Djibouti City', 'Tadjoura', 'Obock', 'Ali Sabieh'],
'Dominican Rep.': ['Santo Domingo', 'Punta Cana', 'Puerto Plata', 'La Romana', 'Samaná', 'Sosúa'],
'Ecuador': ['Quito', 'Guayaquil', 'Cuenca', 'Baños', 'Galápagos Islands', 'Otavalo', 'Montañita'],
'Egypt': ['Cairo', 'Alexandria', 'Luxor', 'Aswan', 'Hurghada', 'Sharm el-Sheikh', 'Giza', 'Dahab'],
'El Salvador': ['San Salvador', 'Santa Ana', 'San Miguel', 'La Libertad', 'Suchitoto'],
'Eq. Guinea': ['Malabo', 'Bata', 'Ebebiyín'],
'Eritrea': ['Asmara', 'Massawa', 'Keren', 'Assab'],
'Estonia': ['Tallinn', 'Tartu', 'Pärnu', 'Kuressaare', 'Narva'],
'Eswatini': ['Mbabane', 'Manzini', 'Big Bend', 'Mhlume'],
'Ethiopia': ['Addis Ababa', 'Lalibela', 'Gondar', 'Axum', 'Bahir Dar', 'Harar'],
'Faeroe Is.': ['Tórshavn', 'Klaksvík', 'Runavík', 'Vestmanna'],
'Fiji': ['Suva', 'Nadi', 'Lautoka', 'Denarau', 'Coral Coast'],
'Finland': ['Helsinki', 'Rovaniemi', 'Tampere', 'Turku', 'Levi', 'Savonlinna', 'Porvoo'],
'France': ['Paris', 'Nice', 'Marseille', 'Lyon', 'Bordeaux', 'Toulouse', 'Strasbourg', 'Lille', 'Montpellier', 'Avignon', 'Arles', 'Cannes', 'Saint-Tropez', 'Annecy', 'Chamonix', 'Biarritz', 'Colmar'],
'Gabon': ['Libreville', 'Port-Gentil', 'Franceville', 'Oyem'],
'Gambia': ['Banjul', 'Serrekunda', 'Brikama', 'Bakau'],
'Georgia': ['Tbilisi', 'Batumi', 'Kutaisi', 'Stepantsminda', 'Sighnaghi', 'Telavi', 'Mestia'],
'Germany': ['Berlin', 'Munich', 'Hamburg', 'Frankfurt', 'Cologne', 'Stuttgart', 'Düsseldorf', 'Dresden', 'Leipzig', 'Nuremberg', 'Heidelberg', 'Freiburg', 'Hannover', 'Bremen', 'Bonn', 'Rothenburg ob der Tauber', 'Neuschwanstein'],
'Ghana': ['Accra', 'Kumasi', 'Cape Coast', 'Tamale', 'Elmina', 'Takoradi'],
'Greece': ['Athens', 'Santorini', 'Mykonos', 'Crete', 'Thessaloniki', 'Corfu', 'Rhodes', 'Naxos', 'Paros', 'Milos', 'Delphi', 'Meteora', 'Olympia', 'Zakynthos'],
'Greenland': ['Nuuk', 'Ilulissat', 'Kangerlussuaq', 'Sisimiut'],
'Grenada': ['St. George\'s', 'Gouyave', 'Grenville', 'Sauteurs'],
'Guatemala': ['Guatemala City', 'Antigua', 'Lake Atitlán', 'Flores', 'Chichicastenango', 'Quetzaltenango', 'Semuc Champey'],
'Guinea': ['Conakry', 'Kindia', 'Kankan', 'N\'Zérékoré', 'Labé'],
'Guinea-Bissau': ['Bissau', 'Bafatá', 'Gabú', 'Cacheu'],
'Guyana': ['Georgetown', 'Linden', 'New Amsterdam', 'Bartica'],
'Haiti': ['Port-au-Prince', 'Cap-Haïtien', 'Jacmel', 'Les Cayes', 'Gonaïves'],
'Honduras': ['Tegucigalpa', 'San Pedro Sula', 'La Ceiba', 'Roatán', 'Copán Ruinas'],
'Hungary': ['Budapest', 'Debrecen', 'Szeged', 'Pécs', 'Eger', 'Siófok (Lake Balaton)', 'Visegrád', 'Hévíz'],
'Iceland': ['Reykjavík', 'Akureyri', 'Vík', 'Höfn', 'Ísafjörður', 'Blue Lagoon', 'Thingvellir'],
'India': ['Mumbai', 'Delhi', 'Jaipur', 'Agra', 'Varanasi', 'Goa', 'Kerala', 'Bangalore', 'Chennai', 'Kolkata', 'Hyderabad', 'Udaipur', 'Jaisalmer', 'Rishikesh', 'Darjeeling', 'Amritsar', 'Leh', 'Hampi', 'Mysore', 'Pondicherry'],
'Indonesia': ['Bali (Denpasar)', 'Jakarta', 'Yogyakarta', 'Lombok', 'Komodo', 'Surabaya', 'Bandung', 'Medan', 'Makassar', 'Labuan Bajo', 'Raja Ampat', 'Gili Islands'],
'Iran': ['Tehran', 'Isfahan', 'Shiraz', 'Mashhad', 'Tabriz', 'Yazd', 'Kashan'],
'Iraq': ['Baghdad', 'Erbil', 'Basra', 'Najaf', 'Karbala', 'Sulaymaniyah'],
'Ireland': ['Dublin', 'Galway', 'Cork', 'Killarney', 'Dingle', 'Cliffs of Moher', 'Kilkenny', 'Belfast (NI)', 'Ring of Kerry'],
'Israel': ['Tel Aviv', 'Jerusalem', 'Haifa', 'Eilat', 'Dead Sea', 'Nazareth', 'Tiberias', 'Caesarea', 'Akko'],
'Italy': ['Rome', 'Florence', 'Venice', 'Milan', 'Naples', 'Cinque Terre', 'Amalfi Coast', 'Positano', 'Capri', 'Verona', 'Bologna', 'Turin', 'Siena', 'Lake Como', 'Pisa', 'Palermo', 'Catania', 'Matera', 'Tuscany', 'Dolomites'],
'Jamaica': ['Kingston', 'Montego Bay', 'Negril', 'Ocho Rios', 'Port Antonio'],
'Japan': ['Tokyo', 'Osaka', 'Kyoto', 'Sapporo', 'Fukuoka', 'Hiroshima', 'Nara', 'Kanazawa', 'Nagoya', 'Yokohama', 'Kobe', 'Hakone', 'Nikko', 'Miyajima', 'Takayama', 'Okinawa', 'Kamakura', 'Fuji Five Lakes'],
'Jordan': ['Amman', 'Petra', 'Wadi Rum', 'Dead Sea', 'Aqaba', 'Madaba', 'Jerash'],
'Kazakhstan': ['Almaty', 'Nur-Sultan', 'Shymkent', 'Aktau', 'Karaganda'],
'Kenya': ['Nairobi', 'Mombasa', 'Masai Mara', 'Diani Beach', 'Amboseli', 'Lake Nakuru', 'Tsavo', 'Nanyuki'],
'Kiribati': ['Tarawa', 'Kiritimati (Christmas Island)'],
'Kosovo': ['Pristina', 'Prizren', 'Peja', 'Gjakova', 'Mitrovica'],
'Kuwait': ['Kuwait City', 'Salmiya', 'Hawally', 'Ahmadi', 'Jahra'],
'Kyrgyzstan': ['Bishkek', 'Osh', 'Karakol', 'Jalal-Abad', 'Talas'],
'Laos': ['Vientiane', 'Luang Prabang', 'Vang Vieng', 'Pakse', 'Savannakhet', 'Si Phan Don (4000 Islands)'],
'Latvia': ['Riga', 'Jūrmala', 'Liepāja', 'Cēsis', 'Sigulda', 'Daugavpils'],
'Lebanon': ['Beirut', 'Byblos', 'Baalbek', 'Tripoli', 'Sidon', 'Tyre', 'Jounieh'],
'Lesotho': ['Maseru', 'Teyateyaneng', 'Mafeteng', 'Hlotse'],
'Liberia': ['Monrovia', 'Buchanan', 'Ganta', 'Harper', 'Robertsport'],
'Libya': ['Tripoli', 'Benghazi', 'Misrata', 'Sabratha', 'Leptis Magna'],
'Liechtenstein': ['Vaduz', 'Schaan', 'Balzers', 'Triesenberg'],
'Lithuania': ['Vilnius', 'Kaunas', 'Klaipėda', 'Šiauliai', 'Trakai', 'Palanga', 'Nida'],
'Luxembourg': ['Luxembourg City', 'Echternach', 'Vianden', 'Ettelbruck'],
'Madagascar': ['Antananarivo', 'Nosy Be', 'Morondava', 'Fianarantsoa', 'Isalo', 'Tôlanaro'],
'Malawi': ['Lilongwe', 'Blantyre', 'Mzuzu', 'Lake Malawi', 'Zomba'],
'Malaysia': ['Kuala Lumpur', 'Penang (George Town)', 'Langkawi', 'Borneo (Kota Kinabalu)', 'Malacca', 'Cameron Highlands', 'Johor Bahru', 'Kuching', 'Sipadan Island'],
'Maldives': ['Malé', 'Ari Atoll', 'Baa Atoll', 'South Male Atoll', 'Addu City'],
'Mali': ['Bamako', 'Timbuktu', 'Ségou', 'Mopti', 'Djenné'],
'Malta': ['Valletta', 'Sliema', 'Gozo', 'Mellieħa', 'Mdina', 'St. Julian\'s', 'Comino'],
'Marshall Is.': ['Majuro', 'Kwajalein', 'Ebeye'],
'Mauritania': ['Nouakchott', 'Nouadhibou', 'Atar', 'Chinguetti', 'Ouadane'],
'Mauritius': ['Port Louis', 'Grand Baie', 'Flic en Flac', 'Belle Mare', 'Le Morne', 'Chamarel'],
'Mexico': ['Mexico City', 'Cancún', 'Playa del Carmen', 'Tulum', 'Guadalajara', 'Monterrey', 'Puerto Vallarta', 'Oaxaca', 'San Miguel de Allende', 'Mérida', 'Cabo San Lucas', 'Guanajuato', 'Chichen Itza', 'Palenque', 'Cuernavaca', 'Puebla'],
'Micronesia': ['Palikir', 'Chuuk', 'Pohnpei', 'Yap', 'Kosrae'],
'Moldova': ['Chișinău', 'Bălți', 'Tiraspol', 'Cahul', 'Orhei'],
'Monaco': ['Monaco City', 'Monte Carlo', 'La Condamine', 'Fontvieille'],
'Mongolia': ['Ulaanbaatar', 'Karakorum', 'Gobi Desert', 'Lake Khövsgöl', 'Altai', 'Erdenet'],
'Montenegro': ['Podgorica', 'Kotor', 'Budva', 'Bar', 'Ulcinj', 'Žabljak', 'Perast'],
'Morocco': ['Marrakech', 'Fes', 'Casablanca', 'Rabat', 'Tangier', 'Chefchaouen', 'Essaouira', 'Ouarzazate', 'Agadir', 'Meknes', 'Merzouga (Sahara)'],
'Mozambique': ['Maputo', 'Beira', 'Tofo (Inhambane)', 'Vilankulo', 'Bazaruto Archipelago', 'Nampula'],
'Myanmar': ['Yangon', 'Mandalay', 'Bagan', 'Inle Lake', 'Hpa-An', 'Ngapali Beach'],
'Namibia': ['Windhoek', 'Swakopmund', 'Sossusvlei', 'Etosha National Park', 'Fish River Canyon', 'Walvis Bay'],
'Nauru': ['Yaren', 'Boe', 'Aiwo'],
'Nepal': ['Kathmandu', 'Pokhara', 'Chitwan', 'Lumbini', 'Everest Base Camp', 'Nagarkot', 'Bandipur'],
'Netherlands': ['Amsterdam', 'Rotterdam', 'The Hague', 'Utrecht', 'Maastricht', 'Groningen', 'Leiden', 'Delft', 'Giethoorn', 'Haarlem', 'Zaanse Schans', 'Keukenhof'],
'New Zealand': ['Auckland', 'Queenstown', 'Wellington', 'Christchurch', 'Rotorua', 'Milford Sound', 'Wanaka', 'Taupō', 'Dunedin', 'Tongariro', 'Abel Tasman', 'Bay of Islands'],
'Nicaragua': ['Managua', 'Granada', 'León', 'San Juan del Sur', 'Ometepe Island', 'Corn Islands'],
'Niger': ['Niamey', 'Agadez', 'Zinder', 'Maradi', 'Tahoua'],
'Nigeria': ['Lagos', 'Abuja', 'Port Harcourt', 'Calabar', 'Ibadan', 'Kano', 'Enugu', 'Jos'],
'North Korea': ['Pyongyang', 'Kaesong', 'Chongjin', 'Nampo', 'Wonsan'],
'North Macedonia': ['Skopje', 'Ohrid', 'Bitola', 'Tetovo', 'Struga'],
'Norway': ['Oslo', 'Bergen', 'Tromsø', 'Stavanger', 'Trondheim', 'Lofoten Islands', 'Geirangerfjord', 'Flåm', 'Alesund', 'Preikestolen', 'Nordkapp'],
'Oman': ['Muscat', 'Salalah', 'Nizwa', 'Sur', 'Wahiba Sands', 'Khasab', 'Sohar'],
'Pakistan': ['Islamabad', 'Karachi', 'Lahore', 'Hunza Valley', 'Skardu', 'Faisalabad', 'Multan', 'Swat Valley'],
'Palau': ['Ngerulmud', 'Koror', 'Peleliu'],
'Palestine': ['Ramallah', 'Bethlehem', 'Hebron', 'Nablus', 'Jericho', 'Gaza'],
'Panama': ['Panama City', 'Bocas del Toro', 'Boquete', 'San Blas Islands', 'El Valle de Antón'],
'Papua New Guinea': ['Port Moresby', 'Lae', 'Mount Hagen', 'Kokopo', 'Alotau', 'Tufi'],
'Paraguay': ['Asunción', 'Ciudad del Este', 'Encarnación', 'San Bernardino', 'Filadelfia'],
'Peru': ['Lima', 'Cusco', 'Arequipa', 'Machu Picchu', 'Sacred Valley', 'Lake Titicaca', 'Iquitos (Amazon)', 'Paracas', 'Huaraz', 'Nazca', 'Máncora', 'Trujillo'],
'Philippines': ['Manila', 'Cebu', 'Palawan (El Nido)', 'Siargao', 'Boracay', 'Davao', 'Bohol (Panglao)', 'Banaue Rice Terraces', 'Coron', 'Baguio', 'Puerto Princesa'],
'Poland': ['Warsaw', 'Kraków', 'Gdańsk', 'Wrocław', 'Poznań', 'Zakopane', 'Gdynia', 'Łódź', 'Toruń', 'Szczecin', 'Lublin', 'Malbork', 'Morskie Oko'],
'Portugal': ['Lisbon', 'Porto', 'Algarve (Faro)', 'Sintra', 'Madeira', 'Coimbra', 'Azores', 'Braga', 'Évora', 'Cascais', 'Douro Valley'],
'Puerto Rico': ['San Juan', 'Ponce', 'Mayagüez', 'Culebra', 'Vieques', 'Rincón'],
'Qatar': ['Doha', 'Al Wakrah', 'Al Khor', 'Mesaieed', 'Katara'],
'Romania': ['Bucharest', 'Cluj-Napoca', 'Brașov', 'Sibiu', 'Sighișoara', 'Timișoara', 'Iași', 'Constanța', 'Transfăgărășan', 'Mamaia'],
'Russia': ['Moscow', 'Saint Petersburg', 'Moscow', 'Sochi', 'Vladivostok', 'Kazan', 'Novosibirsk', 'Yekaterinburg', 'Irkutsk', 'Lake Baikal', 'Murmansk', 'Kaliningrad', 'Kamchatka', 'Krasnodar', 'Nizhny Novgorod', 'Rostov-on-Don'],
'Rwanda': ['Kigali', 'Butare', 'Gisenyi', 'Volcanoes National Park', 'Akagera', 'Nyungwe Forest'],
'S. Sudan': ['Juba', 'Malakal', 'Wau', 'Bor', 'Yei'],
'Samoa': ['Apia', 'Salelologa', 'Lalomanu', 'Safua'],
'San Marino': ['San Marino City', 'Borgo Maggiore', 'Serravalle'],
'São Tomé and Principe': ['São Tomé', 'Santo António', 'Neves'],
'Saudi Arabia': ['Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'AlUla', 'Abha', 'Tabuk', 'Neom'],
'Senegal': ['Dakar', 'Saint-Louis', 'Gorée Island', 'Sine-Saloum Delta', 'Pink Lake (Lac Rose)', 'Cap Skirring'],
'Serbia': ['Belgrade', 'Novi Sad', 'Niš', 'Subotica', 'Kragujevac', 'Zlatibor', 'Kopaonik'],
'Seychelles': ['Mahé (Victoria)', 'Praslin', 'La Digue', 'Silhouette Island'],
'Sierra Leone': ['Freetown', 'Bo', 'Kenema', 'Makeni', 'Bunce Island'],
'Singapore': ['Singapore'],
'Slovakia': ['Bratislava', 'Košice', 'Tatras (High Tatras)', 'Banská Štiavnica', 'Levoča', 'Žilina', 'Poprad'],
'Slovenia': ['Ljubljana', 'Lake Bled', 'Piran', 'Maribor', 'Postojna Cave', 'Triglav National Park', 'Celje'],
'Solomon Is.': ['Honiara', 'Gizo', 'Auki', 'Munda'],
'Somalia': ['Mogadishu', 'Hargeisa', 'Kismayo', 'Baidoa', 'Berbera'],
'South Africa': ['Cape Town', 'Johannesburg', 'Durban', 'Kruger National Park', 'Garden Route', 'Cape Winelands (Stellenbosch)', 'Port Elizabeth', 'Hermanus', 'Blyde River Canyon', 'Drakensberg', 'Pretoria', 'Soweto', 'Knysna'],
'South Korea': ['Seoul', 'Busan', 'Jeju Island', 'Gyeongju', 'Incheon', 'Daegu', 'Daejeon', 'Gwangju', 'Jeonju', 'Seoraksan National Park', 'Andong', 'Suwon', 'Sokcho', 'Pyeongchang'],
'Spain': ['Barcelona', 'Madrid', 'Seville', 'Granada', 'Valencia', 'Bilbao', 'San Sebastián', 'Mallorca', 'Ibiza', 'Tenerife', 'Córdoba', 'Málaga', 'Santiago de Compostela', 'Toledo', 'Ronda', 'Salamanca', 'Marbella', 'Costa Brava', 'Alhambra', 'Picos de Europa'],
'Sri Lanka': ['Colombo', 'Kandy', 'Galle', 'Sigiriya', 'Ella', 'Mirissa', 'Anuradhapura', 'Polonnaruwa', 'Nuwara Eliya', 'Yala National Park'],
'St. Kitts and Nevis': ['Basseterre', 'Charlestown', 'Frigate Bay', 'Nevis'],
'St. Lucia': ['Castries', 'Soufrière', 'Gros Islet', 'Vieux Fort', 'Marigot Bay'],
'St. Pierre and Miquelon': ['Saint-Pierre', 'Miquelon'],
'St. Vin. and Gren.': ['Kingstown', 'Bequia', 'Mustique', 'Canouan', 'Union Island'],
'Sudan': ['Khartoum', 'Omdurman', 'Port Sudan', 'Kassala', 'Nyala'],
'Suriname': ['Paramaribo', 'Lelydorp', 'Brokopondo', 'Nieuw Nickerie'],
'Sweden': ['Stockholm', 'Gothenburg', 'Malmö', 'Kiruna', 'Visby', 'Uppsala', 'Lund', 'Abisko National Park', 'Icehotel (Jukkasjärvi)', 'Smögen'],
'Switzerland': ['Zurich', 'Geneva', 'Lucerne', 'Zermatt', 'Interlaken', 'Lugano', 'Lausanne', 'Bern', 'Grindelwald', 'St. Moritz', 'Jungfraujoch', 'Montreux', 'Matterhorn Glacier Paradise'],
'Syria': ['Damascus', 'Aleppo', 'Palmyra', 'Homs', 'Latakia', 'Maaloula'],
'Taiwan': ['Taipei', 'Taichung', 'Kaohsiung', 'Tainan', 'Taroko Gorge', 'Sun Moon Lake', 'Alishan', 'Jiufen', 'Kenting', 'Yilan', 'Taoyuan', 'Hualien'],
'Tajikistan': ['Dushanbe', 'Khujand', 'Pamir Mountains', 'Khorog', 'Bokhtar'],
'Tanzania': ['Dar es Salaam', 'Zanzibar City', 'Arusha', 'Serengeti National Park', 'Kilimanjaro', 'Ngorongoro Crater', 'Mwanza', 'Mbeya', 'Mafia Island', 'Lake Manyara', 'Selous'],
'Thailand': ['Bangkok', 'Chiang Mai', 'Phuket', 'Krabi', 'Pattaya', 'Koh Samui', 'Koh Phi Phi', 'Koh Tao', 'Ayutthaya', 'Chiang Rai', 'Hua Hin', 'Khao Sok', 'Pai', 'Kanchanaburi', 'Sukhothai', 'Koh Lanta', 'Railay Beach', 'Erawan National Park'],
'Timor-Leste': ['Dili', 'Baucau', 'Same', 'Atauro Island', 'Jaco Island'],
'Togo': ['Lomé', 'Kpalimé', 'Sokodé', 'Kara', 'Aneho'],
'Tonga': ['Nuku\'alofa', 'Neiafu', 'Pangai', 'Ha\'apai', 'Eua'],
'Trinidad and Tobago': ['Port of Spain', 'San Fernando', 'Tobago (Scarborough)', 'Chaguanas', 'Maracas Bay'],
'Tunisia': ['Tunis', 'Sousse', 'Hammamet', 'Djerba', 'Sfax', 'Carthage', 'Douz (Sahara)', 'Matmata', 'Bizerte', 'El Jem'],
'Turkey': ['Istanbul', 'Cappadocia (Göreme)', 'Antalya', 'Izmir', 'Bodrum', 'Fethiye', 'Pamukkale', 'Ephesus', 'Marmaris', 'Alanya', 'Ankara', 'Trabzon', 'Kas', 'Olympos', 'Gallipoli', 'Konya', 'Mardin', 'Butterfly Valley'],
'Turkmenistan': ['Ashgabat', 'Mary', 'Turkmenbashi', 'Dashoguz', 'Köneürgenç'],
'Tuvalu': ['Funafuti', 'Nanumea', 'Nukulaelae'],
'U.S. Virgin Is.': ['Charlotte Amalie', 'Christiansted', 'Frederiksted', 'Cruz Bay'],
'Uganda': ['Kampala', 'Jinja', 'Murchison Falls', 'Queen Elizabeth National Park', 'Bwindi Impenetrable Forest', 'Kibale', 'Lake Bunyonyi', 'Entebbe'],
'Ukraine': ['Kyiv', 'Lviv', 'Odesa', 'Kharkiv', 'Carpathian Mountains', 'Dnipro', 'Chernivtsi', 'Kamianets-Podilskyi', 'Zaporizhzhia', 'Lutsk'],
'United Arab Emirates': ['Dubai', 'Abu Dhabi', 'Sharjah', 'Fujairah', 'Ras Al Khaimah', 'Ajman', 'Hatta'],
'United Kingdom': ['London', 'Edinburgh', 'Bath', 'York', 'Liverpool', 'Manchester', 'Birmingham', 'Cambridge', 'Oxford', 'Brighton', 'Cornwall', 'Bristol', 'Cardiff', 'Glasgow', 'Inverness', 'Belfast', 'Lake District', 'Scottish Highlands', 'St. Ives', 'Canterbury', 'Dover', 'Stratford-upon-Avon'],
'United States of America': ['New York', 'Los Angeles', 'Chicago', 'San Francisco', 'Las Vegas', 'Miami', 'Orlando', 'Washington DC', 'Boston', 'Seattle', 'Portland', 'Denver', 'New Orleans', 'Nashville', 'Austin', 'San Diego', 'Honolulu', 'Grand Canyon', 'Yellowstone', 'Yosemite', 'Houston', 'Dallas', 'Atlanta', 'Philadelphia', 'Phoenix', 'San Antonio', 'Savannah', 'Charleston', 'Santa Fe', 'Anchorage', 'Maui', 'Kauai', 'Moab', 'Portland (ME)', 'Asheville', 'Sedona', 'Napa Valley'],
'Uruguay': ['Montevideo', 'Punta del Este', 'Colonia del Sacramento', 'Piriapolis', 'Cabo Polonio', 'Rocha'],
'Uzbekistan': ['Tashkent', 'Samarkand', 'Bukhara', 'Khiva', 'Shakhrisabz', 'Fergana', 'Nukus', 'Termez'],
'Vanuatu': ['Port Vila', 'Luganville', 'Tanna Island', 'Pentecost', 'Espiritu Santo'],
'Vatican': ['Vatican City'],
'Venezuela': ['Caracas', 'Angel Falls (Canaima)', 'Margarita Island', 'Los Roques', 'Mérida', 'Roraima', 'Maracaibo', 'Valencia'],
'Vietnam': ['Hanoi', 'Ho Chi Minh City (Saigon)', 'Ha Long Bay', 'Hoi An', 'Da Nang', 'Hue', 'Nha Trang', 'Phong Nha', 'Da Lat', 'Sapa', 'Phu Quoc', 'Mui Ne', 'Con Dao', 'Mekong Delta (Can Tho)', 'Ninh Binh', 'Son Doong Cave'],
'W. Sahara': ['Laayoune', 'Dakhla', 'Smara', 'Boujdour'],
'Yemen': ['Sana\'a', 'Aden', 'Socotra', 'Taiz', 'Mukalla', 'Shibam'],
'Zambia': ['Lusaka', 'Victoria Falls', 'Livingstone', 'South Luangwa National Park', 'Kitwe', 'Ndola', 'Kasama'],
'Zimbabwe': ['Harare', 'Victoria Falls', 'Bulawayo', 'Hwange National Park', 'Mutare', 'Gweru', 'Masvingo (Great Zimbabwe)'],
};
/**
* Get curated city suggestions for a given country name.
* @param {string} countryName
* @returns {string[]}
*/
export function getCitiesForCountry(countryName) {
return ALL_CITIES[countryName] || [];
}

View File

@@ -0,0 +1,184 @@
export const countryCities = {
'Afghanistan': ['Kabul','Kandahar','Herat','Mazar-i-Sharif','Jalalabad'],
'Albania': ['Tirana','Durrës','Vlorë','Shkodër','Elbasan'],
'Algeria': ['Algiers','Oran','Constantine','Annaba','Blida'],
'Andorra': ['Andorra la Vella','Escaldes-Engordany','Encamp'],
'Angola': ['Luanda','Huambo','Lobito','Benguela','Lubango'],
'Argentina': ['Buenos Aires','Córdoba','Rosario','Mendoza','Bariloche','Salta','Mar del Plata'],
'Armenia': ['Yerevan','Gyumri','Vanadzor'],
'Australia': ['Sydney','Melbourne','Brisbane','Perth','Adelaide','Gold Coast','Cairns','Darwin','Hobart','Canberra'],
'Austria': ['Vienna','Salzburg','Graz','Innsbruck','Linz','Hallstatt'],
'Azerbaijan': ['Baku','Ganja','Sumqayit'],
'Bahamas': ['Nassau','Freeport'],
'Bahrain': ['Manama','Riffa','Muharraq'],
'Bangladesh': ['Dhaka','Chittagong','Sylhet','Rajshahi','Khulna'],
'Barbados': ['Bridgetown'],
'Belarus': ['Minsk','Gomel','Brest','Grodno'],
'Belgium': ['Brussels','Bruges','Ghent','Antwerp','Liège'],
'Belize': ['Belize City','San Ignacio','Placencia'],
'Benin': ['Cotonou','Porto-Novo','Abomey'],
'Bhutan': ['Thimphu','Paro','Punakha'],
'Bolivia': ['La Paz','Santa Cruz','Cochabamba','Sucre','Uyuni'],
'Bosnia and Herz.': ['Sarajevo','Mostar','Banja Luka'],
'Botswana': ['Gaborone','Francistown','Maun'],
'Brazil': ['São Paulo','Rio de Janeiro','Brasília','Salvador','Fortaleza','Manaus','Recife','Florianópolis','Foz do Iguaçu'],
'Brunei': ['Bandar Seri Begawan'],
'Bulgaria': ['Sofia','Plovdiv','Varna','Burgas','Ruse'],
'Burkina Faso': ['Ouagadougou','Bobo-Dioulasso'],
'Burundi': ['Bujumbura','Gitega'],
'Cabo Verde': ['Praia','Mindelo'],
'Cambodia': ['Phnom Penh','Siem Reap','Sihanoukville','Battambang'],
'Cameroon': ['Yaoundé','Douala','Bafoussam'],
'Canada': ['Toronto','Vancouver','Montreal','Calgary','Ottawa','Edmonton','Quebec City','Whistler','Banff','Niagara Falls','Halifax'],
'Central African Rep.': ['Bangui'],
'Chad': ["N'Djamena",'Moundou'],
'Chile': ['Santiago','Valparaíso','Atacama','Puerto Natales','Punta Arenas','Viña del Mar','San Pedro de Atacama'],
'China': ['Beijing','Shanghai','Guangzhou','Shenzhen','Chengdu','Xi\'an','Hangzhou','Chongqing','Guilin','Zhangjiajie','Lijiang','Hong Kong','Macau'],
'Colombia': ['Bogotá','Medellín','Cartagena','Cali','Santa Marta','Barranquilla'],
'Comoros': ['Moroni'],
'Congo': ['Brazzaville','Pointe-Noire'],
'Costa Rica': ['San José','Manuel Antonio','Tamarindo','Arenal','Monteverde'],
'Croatia': ['Zagreb','Dubrovnik','Split','Hvar','Zadar','Pula','Rijeka'],
'Cuba': ['Havana','Trinidad','Varadero','Santiago de Cuba','Cienfuegos'],
'Cyprus': ['Nicosia','Limassol','Paphos','Larnaca','Ayia Napa'],
'Czechia': ['Prague','Brno','Český Krumlov','Karlovy Vary','Olomouc'],
"Côte d'Ivoire": ['Abidjan','Yamoussoukro','Bouaké'],
'Dem. Rep. Congo': ['Kinshasa','Lubumbashi','Goma','Kisangani'],
'Denmark': ['Copenhagen','Aarhus','Odense','Aalborg'],
'Djibouti': ['Djibouti City'],
'Dominican Rep.': ['Santo Domingo','Punta Cana','Santiago','La Romana'],
'Ecuador': ['Quito','Guayaquil','Cuenca','Baños','Galápagos Islands'],
'Egypt': ['Cairo','Alexandria','Luxor','Aswan','Sharm el-Sheikh','Hurghada','Giza'],
'El Salvador': ['San Salvador','Santa Ana','San Miguel'],
'Eq. Guinea': ['Malabo','Bata'],
'Eritrea': ['Asmara','Massawa'],
'Estonia': ['Tallinn','Tartu','Pärnu'],
'Ethiopia': ['Addis Ababa','Lalibela','Gondar','Axum','Dire Dawa'],
'Fiji': ['Suva','Nadi','Mamanuca Islands'],
'Finland': ['Helsinki','Rovaniemi','Tampere','Turku','Oulu'],
'Fr. Polynesia': ['Papeete','Bora Bora','Moorea'],
'France': ['Paris','Nice','Lyon','Marseille','Bordeaux','Strasbourg','Toulouse','Cannes','Monaco','Mont Saint-Michel','Versailles'],
'Gabon': ['Libreville','Port-Gentil'],
'Gambia': ['Banjul','Serekunda'],
'Georgia': ['Tbilisi','Batumi','Kutaisi','Sighnaghi'],
'Germany': ['Berlin','Munich','Hamburg','Frankfurt','Cologne','Dresden','Heidelberg','Rothenburg ob der Tauber','Neuschwanstein','Stuttgart'],
'Ghana': ['Accra','Kumasi','Cape Coast','Tamale'],
'Greece': ['Athens','Santorini','Mykonos','Rhodes','Thessaloniki','Crete','Corfu','Meteora'],
'Greenland': ['Nuuk','Ilulissat'],
'Grenada': ["St. George's"],
'Guatemala': ['Guatemala City','Antigua','Lake Atitlán','Tikal','Quetzaltenango'],
'Guinea': ['Conakry'],
'Guyana': ['Georgetown'],
'Haiti': ['Port-au-Prince','Cap-Haïtien'],
'Honduras': ['Tegucigalpa','San Pedro Sula','Roatán'],
'Hong Kong': ['Hong Kong'],
'Hungary': ['Budapest','Debrecen','Pécs','Eger','Győr'],
'Iceland': ['Reykjavik','Akureyri','Blue Lagoon','Golden Circle'],
'India': ['Mumbai','Delhi','Jaipur','Agra','Bangalore','Chennai','Kolkata','Goa','Varanasi','Udaipur','Kerala','Leh','Shimla'],
'Indonesia': ['Jakarta','Bali','Yogyakarta','Lombok','Medan','Komodo','Raja Ampat','Surabaya'],
'Iran': ['Tehran','Isfahan','Shiraz','Persepolis','Yazd'],
'Iraq': ['Baghdad','Erbil','Basra','Najaf'],
'Ireland': ['Dublin','Cork','Galway','Killarney','Limerick'],
'Israel': ['Jerusalem','Tel Aviv','Haifa','Eilat','Dead Sea'],
'Italy': ['Rome','Florence','Venice','Milan','Naples','Amalfi','Sicily','Cinque Terre','Bologna','Turin'],
'Jamaica': ['Kingston','Montego Bay','Negril','Ocho Rios'],
'Japan': ['Tokyo','Kyoto','Osaka','Hiroshima','Nara','Sapporo','Hakone','Nikko','Kanazawa','Okinawa','Fukuoka'],
'Jordan': ['Amman','Petra','Wadi Rum','Aqaba','Jerash'],
'Kazakhstan': ['Almaty','Nur-Sultan','Shymkent'],
'Kenya': ['Nairobi','Mombasa','Masai Mara','Amboseli','Zanzibar'],
'Kosovo': ['Pristina','Prizren'],
'Kuwait': ['Kuwait City'],
'Kyrgyzstan': ['Bishkek','Osh','Karakol'],
'Laos': ['Vientiane','Luang Prabang','Vang Vieng'],
'Latvia': ['Riga','Jūrmala','Sigulda'],
'Lebanon': ['Beirut','Byblos','Baalbek','Sidon'],
'Libya': ['Tripoli','Benghazi','Leptis Magna'],
'Liechtenstein': ['Vaduz'],
'Lithuania': ['Vilnius','Kaunas','Trakai','Klaipėda'],
'Luxembourg': ['Luxembourg City','Vianden'],
'Madagascar': ['Antananarivo','Nosy Be','Morondava'],
'Malawi': ['Lilongwe','Blantyre','Lake Malawi'],
'Malaysia': ['Kuala Lumpur','Penang','Langkawi','Kota Kinabalu','Malacca','George Town'],
'Maldives': ['Malé','Maafushi'],
'Mali': ['Bamako','Timbuktu','Djenné'],
'Malta': ['Valletta','Mdina','Gozo'],
'Mauritania': ['Nouakchott'],
'Mauritius': ['Port Louis','Grand Baie','Flic en Flac'],
'Mexico': ['Mexico City','Cancún','Guadalajara','Oaxaca','Tulum','Playa del Carmen','San Miguel de Allende','Monterrey','Chichen Itza'],
'Moldova': ['Chișinău'],
'Monaco': ['Monaco'],
'Mongolia': ['Ulaanbaatar','Gobi Desert'],
'Montenegro': ['Podgorica','Kotor','Budva','Bar'],
'Morocco': ['Marrakech','Fes','Casablanca','Rabat','Chefchaouen','Essaouira','Sahara Desert'],
'Mozambique': ['Maputo','Beira','Pemba'],
'Myanmar': ['Yangon','Bagan','Mandalay','Inle Lake'],
'Namibia': ['Windhoek','Swakopmund','Etosha','Sossusvlei'],
'Nepal': ['Kathmandu','Pokhara','Everest Base Camp','Chitwan','Lumbini'],
'Netherlands': ['Amsterdam','Rotterdam','The Hague','Utrecht','Delft','Eindhoven'],
'New Zealand': ['Auckland','Queenstown','Wellington','Christchurch','Rotorua','Milford Sound'],
'Nicaragua': ['Managua','Granada','León'],
'Niger': ['Niamey','Agadez'],
'Nigeria': ['Lagos','Abuja','Kano','Ibadan'],
'North Korea': ['Pyongyang'],
'North Macedonia': ['Skopje','Ohrid'],
'Norway': ['Oslo','Bergen','Tromsø','Flåm','Ålesund','Stavanger'],
'Oman': ['Muscat','Nizwa','Salalah','Wahiba Sands'],
'Pakistan': ['Karachi','Lahore','Islamabad','Peshawar','Gilgit'],
'Palestine': ['Ramallah','Bethlehem','Jericho','Hebron'],
'Panama': ['Panama City','Bocas del Toro','Boquete'],
'Papua New Guinea': ['Port Moresby'],
'Paraguay': ['Asunción','Ciudad del Este'],
'Peru': ['Lima','Cusco','Machu Picchu','Arequipa','Puno','Iquitos'],
'Philippines': ['Manila','Cebu','Palawan','Boracay','Davao','Siargao'],
'Poland': ['Warsaw','Kraków','Gdańsk','Wrocław','Poznań','Zakopane'],
'Portugal': ['Lisbon','Porto','Algarve','Sintra','Madeira','Azores','Évora'],
'Puerto Rico': ['San Juan','Ponce','Rincon'],
'Qatar': ['Doha'],
'Romania': ['Bucharest','Transylvania','Cluj-Napoca','Sibiu','Brașov','Sinaia'],
'Russia': ['Moscow','St. Petersburg','Irkutsk','Vladivostok','Sochi','Kazan','Novosibirsk'],
'Rwanda': ['Kigali','Volcanoes National Park'],
'S. Sudan': ['Juba'],
'Saint Lucia': ['Castries','Soufrière'],
'Saudi Arabia': ['Riyadh','Jeddah','Mecca','Medina','AlUla','NEOM'],
'Senegal': ['Dakar','Saint-Louis','Ziguinchor'],
'Serbia': ['Belgrade','Novi Sad','Niš'],
'Seychelles': ['Victoria','La Digue','Praslin','Mahé'],
'Sierra Leone': ['Freetown'],
'Singapore': ['Singapore'],
'Slovakia': ['Bratislava','Košice','Banská Bystrica'],
'Slovenia': ['Ljubljana','Bled','Piran','Maribor'],
'Solomon Is.': ['Honiara'],
'Somalia': ['Mogadishu'],
'South Africa': ['Cape Town','Johannesburg','Durban','Stellenbosch','Kruger','Garden Route','Pretoria'],
'South Korea': ['Seoul','Busan','Jeju','Gyeongju','Incheon','Suwon'],
'Spain': ['Barcelona','Madrid','Seville','Granada','Valencia','Bilbao','Toledo','San Sebastián','Ibiza','Mallorca'],
'Sri Lanka': ['Colombo','Kandy','Galle','Ella','Sigiriya','Mirissa'],
'Sudan': ['Khartoum','Omdurman'],
'Suriname': ['Paramaribo'],
'Sweden': ['Stockholm','Gothenburg','Malmö','Uppsala','Kiruna'],
'Switzerland': ['Zurich','Geneva','Bern','Interlaken','Lucerne','Zermatt','Lugano','Grindelwald'],
'Syria': ['Damascus','Aleppo','Palmyra'],
'São Tomé and Príncipe': ['São Tomé'],
'Taiwan': ['Taipei','Kaohsiung','Tainan','Taichung'],
'Tajikistan': ['Dushanbe','Khujand'],
'Tanzania': ['Dar es Salaam','Zanzibar','Serengeti','Arusha','Kilimanjaro'],
'Thailand': ['Bangkok','Chiang Mai','Phuket','Koh Samui','Koh Phi Phi','Ayutthaya','Pai','Krabi'],
'Timor-Leste': ['Dili'],
'Togo': ['Lomé'],
'Trinidad and Tobago': ['Port of Spain'],
'Tunisia': ['Tunis','Carthage','Sousse','Hammamet','Djerba'],
'Turkey': ['Istanbul','Cappadocia','Antalya','Bodrum','Ankara','Ephesus','Pamukkale','Trabzon'],
'Turkmenistan': ['Ashgabat','Merv'],
'Uganda': ['Kampala','Bwindi','Jinja'],
'Ukraine': ['Kyiv','Lviv','Odessa','Kharkiv'],
'United Arab Emirates': ['Dubai','Abu Dhabi','Sharjah'],
'United Kingdom': ['London','Edinburgh','Manchester','Liverpool','Oxford','Cambridge','Bath','York','Brighton','Glasgow','Dublin'],
'United States of America': ['New York','Los Angeles','Chicago','Miami','San Francisco','Las Vegas','New Orleans','Seattle','Boston','Washington D.C.','Nashville','Denver','Honolulu','Anchorage','Portland'],
'Uruguay': ['Montevideo','Punta del Este','Colonia del Sacramento'],
'Uzbekistan': ['Tashkent','Samarkand','Bukhara','Khiva'],
'Venezuela': ['Caracas','Medellín','Canaima','Los Roques'],
'Vietnam': ['Hanoi','Ho Chi Minh City','Hoi An','Da Nang','Ha Long Bay','Hue','Sapa','Phu Quoc'],
'Yemen': ["Sana'a",'Aden'],
'Zambia': ['Lusaka','Livingstone','Victoria Falls'],
'Zimbabwe': ['Harare','Bulawayo','Victoria Falls'],
};

15
src/lib/shared/types.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* @typedef {{
* id: string,
* title: string,
* date: string,
* location: { country: string, cities: string[] },
* photos: string[],
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
* tripType: 'solo' | 'friends' | 'family',
* days: number,
* memo: string
* }} JournalEntry
*/
export {};

View File

@@ -1,45 +0,0 @@
import { db } from '../firebase.js';
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
let entries = $state([]);
let _uid = null;
let _unsubscribe = null;
export function getEntries() {
return entries;
}
export function initEntriesListener(uid) {
if (_unsubscribe) _unsubscribe();
_uid = uid;
const q = query(
collection(db, 'users', uid, 'entries'),
orderBy('createdAt', 'desc')
);
_unsubscribe = onSnapshot(q, (snap) => {
entries = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
});
}
export async function addEntry(data) {
if (!_uid) return null;
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
...data,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});
return ref.id;
}
export async function updateEntry(id, data) {
if (!_uid) return;
await updateDoc(doc(db, 'users', _uid, 'entries', id), {
...data,
updatedAt: serverTimestamp(),
});
}
export async function removeEntry(id) {
if (!_uid) return;
await deleteDoc(doc(db, 'users', _uid, 'entries', id));
}

View File

@@ -1,10 +1,13 @@
import { db } from '../firebase.js';
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
import { writable } from 'svelte/store';
let entries = $state([]);
let _uid = null;
let _unsubscribe = null;
export const journals = writable([]);
export function getEntries() {
return entries;
}
@@ -17,12 +20,14 @@ export function initEntriesListener(uid) {
orderBy('createdAt', 'desc')
);
_unsubscribe = onSnapshot(q, (snap) => {
entries = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
const data = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
entries = data;
journals.set(data);
});
}
export async function addEntry(data) {
if (!_uid) return null;
if (!_uid) throw new Error('Not logged in');
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
...data,
createdAt: serverTimestamp(),

View File

@@ -1,45 +1,2 @@
import { writable } from 'svelte/store';
import { db } from '../firebase.js';
import {
collection, onSnapshot, addDoc, updateDoc, deleteDoc, doc, serverTimestamp
} from 'firebase/firestore';
/**
* @typedef {{
* id: string,
* title: string,
* date: string,
* location: { country: string, cities: string[] },
* photos: string[],
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
* tripType: 'solo' | 'friends' | 'family',
* days: number,
* memo: string
* }} JournalEntry
*/
export const journals = writable(/** @type {JournalEntry[]} */([]));
export const journalsLoading = writable(true);
const entriesRef = collection(db, 'entries');
onSnapshot(entriesRef, (snap) => {
journals.set(snap.docs.map(d => ({ id: d.id, ...d.data() })));
journalsLoading.set(false);
});
/** @param {Omit<JournalEntry, 'id'>} entry */
export async function addJournal(entry) {
await addDoc(entriesRef, { ...entry, createdAt: serverTimestamp() });
}
/** @param {string} id */
export async function removeJournal(id) {
await deleteDoc(doc(db, 'entries', id));
}
/** @param {JournalEntry} updated */
export async function updateJournal(updated) {
const { id, ...data } = updated;
await updateDoc(doc(db, 'entries', id), data);
}
export { journals } from './entriesStore.svelte.js';
export { addEntry as addJournal } from './entriesStore.svelte.js';

View File

@@ -1,454 +0,0 @@
<script>
import { get } from 'svelte/store';
import { journals, addJournal } from '../stores/journalStore.js';
import { countryNames } from '../shared/countries.js';
import SearchInput from '../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
let { initialCountry = '', onBack } = $props();
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
let country = $state(initialCountry);
let date = $state(new Date().toISOString().slice(0, 10));
let days = $state('');
let tripType = $state('');
let transport = $state('');
let photos = $state([]);
let answers = $state(['', '', '']);
let errors = $state({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' });
// ── Steps ──────────────────────────────────────────────────────────
let step = $state(1); // 1 | 2 | 3
// ── Random questions ───────────────────────────────────────────────
const ALL_QUESTIONS = [
'If this trip had a movie title, what would it be?',
'What was the most unexpected thing that happened?',
'Which moment would you relive for just 10 more minutes?',
'What was your best accidental discovery?\n(A café, a street, a person, a view…)',
'If your trip had a theme song, what would it sound like?',
'What did you pack but never use?',
'What was the smallest thing that made you surprisingly happy?',
'If you could steal one thing from this place (without consequences), what would it be?\n(A tradition, a smell, a sunset, a food…)',
'What story from this trip will you probably tell your friends first?',
'What version of yourself showed up on this trip?',
];
function pickRandom() {
const shuffled = [...ALL_QUESTIONS].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 3);
}
const questions = pickRandom();
// ── Helpers ────────────────────────────────────────────────────────
// Suggest cities — if a country is selected, show cities only from that country;
// otherwise show all known cities.
let cityOptions = $derived(
country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
);
function addCity(val) {
const t = (val ?? cityInput).trim();
if (t && !cities.includes(t)) cities = [...cities, t];
cityInput = '';
}
function removeCity(c) { cities = cities.filter(x => x !== c); }
$effect(() => { if (country.trim()) errors.country = ''; });
$effect(() => { if (cities.length > 0) errors.cities = ''; });
$effect(() => { if (date) errors.date = ''; });
$effect(() => { if (days && Number(days) >= 1) errors.days = ''; });
$effect(() => { if (tripType) errors.tripType = ''; });
$effect(() => { if (transport) errors.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' },
];
// ── Navigation ─────────────────────────────────────────────────────
function nextStep() {
if (step === 1) {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
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;
}
step++;
}
function prevStep() {
if (step === 1) onBack();
else step--;
}
// ── Save ───────────────────────────────────────────────────────────
let saving = $state(false);
async function save() {
saving = true;
const memo = questions
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
.filter(Boolean)
.join('\n\n');
try {
await addJournal({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
onBack();
} catch {
saving = false;
}
}
</script>
<div class="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save trip'}
</button>
{/if}
</div>
</header>
<div class="scroll">
<div class="form">
{#if step === 1}
<!-- ── STEP 1: Details ── -->
<h2 class="step-title">Trip details</h2>
<div class="row">
<div class="field">
<label class="label" for="nc-country">Country <span class="req">*</span></label>
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
{#each cities as c}
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
{/each}
</div>
{/if}
</div>
</div>
<div class="row">
<div class="field">
<label class="label" for="nc-date">Date <span class="req">*</span></label>
<input id="nc-date" class="input" type="date" bind:value={date} />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-days">Days <span class="req">*</span></label>
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label">Trip type <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
{t === 'solo' ? 'Solo' : t === 'friends' ? 'With friends' : 'With family'}
</label>
{/each}
</div>
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
</div>
<div class="field">
<label class="label">How did you get there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="transport-opt" class:active={transport === opt.value}>
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
{opt.label}
</label>
{/each}
</div>
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
</div>
{:else if step === 2}
<!-- ── STEP 2: Photos ── -->
<h2 class="step-title">Photos</h2>
<p class="step-sub">Optional — add photos from your trip</p>
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
{:else}
<!-- ── STEP 3: Questions ── -->
<h2 class="step-title">Your memories</h2>
{#each questions as q, i}
<div class="q-card">
<p class="q-text">{q}</p>
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
font-family: var(--sans);
}
/* topbar */
.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;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active {
background: var(--accent);
transform: scale(1.25);
}
.step-dot.done {
background: var(--accent);
opacity: 0.35;
}
.ghost-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 10px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
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;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* scroll + form */
.scroll { flex: 1; overflow-y: auto; }
.form {
max-width: 560px;
margin: 0 auto;
padding: 36px 48px 80px;
display: flex;
flex-direction: column;
gap: 18px;
}
.step-title {
font-size: 20px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.step-sub {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
margin: -10px 0 4px;
}
/* fields (same as EditForm) */
.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; }
.ferr { font-size: 11px; color: #dc2626; }
.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%;
box-sizing: border-box;
}
.input:focus { border-color: var(--accent-border); }
.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); background: var(--bg-subtle);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
white-space: nowrap;
}
.transport-opt input { display: none; }
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.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;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
/* question cards */
.q-card {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.q-text {
font-size: 14px;
font-weight: 400;
color: var(--text-h);
line-height: 1.5;
margin: 0;
white-space: pre-line;
}
.q-input {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
outline: none;
resize: none;
line-height: 1.6;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.q-input:focus { border-color: var(--accent-border); }
.q-input::placeholder { color: var(--text-sub); font-style: italic; }
</style>

View File

@@ -1,465 +0,0 @@
<script>
import { get } from 'svelte/store';
import { journals, addJournal } from '../stores/journalStore.js';
import { countryNames } from '../shared/countries.js';
import SearchInput from '../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
import airplaneImg from '../../assets/airplane.png';
import trainImg from '../../assets/train.png';
import busImg from '../../assets/bus.png';
import carImg from '../../assets/car.png';
import shipImg from '../../assets/ship.png';
import walkImg from '../../assets/walk.png';
let { initialCountry = '', onBack } = $props();
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
let country = $state(initialCountry);
let date = $state(new Date().toISOString().slice(0, 10));
let days = $state('');
let tripType = $state('');
let transport = $state('');
let photos = $state([]);
let answers = $state(['', '', '']);
let errors = $state({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' });
// ── Steps ──────────────────────────────────────────────────────────
let step = $state(1); // 1 | 2 | 3
// ── Random questions ───────────────────────────────────────────────
const ALL_QUESTIONS = [
'If this trip had a movie title, what would it be?',
'What was the most unexpected thing that happened?',
'Which moment would you relive for just 10 more minutes?',
'What was your best accidental discovery?\n(A café, a street, a person, a view…)',
'If your trip had a theme song, what would it sound like?',
'What did you pack but never use?',
'What was the smallest thing that made you surprisingly happy?',
'If you could steal one thing from this place (without consequences), what would it be?\n(A tradition, a smell, a sunset, a food…)',
'What story from this trip will you probably tell your friends first?',
'What version of yourself showed up on this trip?',
];
function pickRandom() {
const shuffled = [...ALL_QUESTIONS].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 3);
}
const questions = pickRandom();
// ── Helpers ────────────────────────────────────────────────────────
// Suggest cities — if a country is selected, show cities only from that country;
// otherwise show all known cities.
let cityOptions = $derived(
country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
);
function addCity(val) {
const t = (val ?? cityInput).trim();
if (t && !cities.includes(t)) cities = [...cities, t];
cityInput = '';
}
function removeCity(c) { cities = cities.filter(x => x !== c); }
$effect(() => { if (country.trim()) errors.country = ''; });
$effect(() => { if (cities.length > 0) errors.cities = ''; });
$effect(() => { if (date) errors.date = ''; });
$effect(() => { if (days && Number(days) >= 1) errors.days = ''; });
$effect(() => { if (tripType) errors.tripType = ''; });
$effect(() => { if (transport) errors.transport = ''; });
const transportOptions = [
{ value: 'flight', label: 'Flight', img: airplaneImg },
{ value: 'train', label: 'Train', img: trainImg },
{ value: 'bus', label: 'Bus', img: busImg },
{ value: 'car', label: 'Car', img: carImg },
{ value: 'ship', label: 'Ship', img: shipImg },
{ value: 'walk', label: 'Walk', img: walkImg },
];
// ── Navigation ─────────────────────────────────────────────────────
function nextStep() {
if (step === 1) {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
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;
}
step++;
}
function prevStep() {
if (step === 1) onBack();
else step--;
}
// ── Save ───────────────────────────────────────────────────────────
let saving = $state(false);
async function save() {
saving = true;
const memo = questions
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
.filter(Boolean)
.join('\n\n');
try {
await addJournal({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
onBack();
} catch {
saving = false;
}
}
</script>
<div class="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save trip'}
</button>
{/if}
</div>
</header>
<div class="scroll">
<div class="form">
{#if step === 1}
<!-- ── STEP 1: Details ── -->
<h2 class="step-title">Trip details</h2>
<div class="row">
<div class="field">
<label class="label" for="nc-country">Country <span class="req">*</span></label>
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
{#each cities as c}
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
{/each}
</div>
{/if}
</div>
</div>
<div class="row">
<div class="field">
<label class="label" for="nc-date">Date <span class="req">*</span></label>
<input id="nc-date" class="input" type="date" bind:value={date} />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-days">Days <span class="req">*</span></label>
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label">Trip type <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
{t === 'solo' ? 'Solo' : t === 'friends' ? 'With friends' : 'With family'}
</label>
{/each}
</div>
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
</div>
<div class="field">
<label class="label">How did you get there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="transport-opt" class:active={transport === opt.value}>
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
<img src={opt.img} alt={opt.label} class="transport-img" />
<span class="transport-label">{opt.label}</span>
</label>
{/each}
</div>
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
</div>
{:else if step === 2}
<!-- ── STEP 2: Photos ── -->
<h2 class="step-title">Photos</h2>
<p class="step-sub">Optional — add photos from your trip</p>
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
{:else}
<!-- ── STEP 3: Questions ── -->
<h2 class="step-title">Your memories</h2>
{#each questions as q, i}
<div class="q-card">
<p class="q-text">{q}</p>
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
font-family: var(--sans);
}
/* topbar */
.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;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active {
background: var(--accent);
transform: scale(1.25);
}
.step-dot.done {
background: var(--accent);
opacity: 0.35;
}
.ghost-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 10px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
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;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* scroll + form */
.scroll { flex: 1; overflow-y: auto; }
.form {
max-width: 560px;
margin: 0 auto;
padding: 36px 48px 80px;
display: flex;
flex-direction: column;
gap: 18px;
}
.step-title {
font-size: 20px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.step-sub {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
margin: -10px 0 4px;
}
/* fields (same as EditForm) */
.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; }
.ferr { font-size: 11px; color: #dc2626; }
.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%;
box-sizing: border-box;
}
.input:focus { border-color: var(--accent-border); }
.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; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; aspect-ratio: 1;
border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle);
cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.transport-opt input { display: none; }
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); }
.transport-img { width: 60px; height: 60px; object-fit: contain; }
.transport-label {
font-size: 12px; font-weight: 300; color: var(--text-sub);
letter-spacing: 0.02em;
}
.transport-opt.active .transport-label { color: var(--accent); }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.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;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
/* question cards */
.q-card {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.q-text {
font-size: 14px;
font-weight: 400;
color: var(--text-h);
line-height: 1.5;
margin: 0;
white-space: pre-line;
}
.q-input {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
outline: none;
resize: none;
line-height: 1.6;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.q-input:focus { border-color: var(--accent-border); }
.q-input::placeholder { color: var(--text-sub); font-style: italic; }
</style>

View File

@@ -1,458 +0,0 @@
<script>
import { get } from 'svelte/store';
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';
/**
* 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 cityOptions = $derived(
country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).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 addJournal({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
tripType,
memo,
photos,
transport,
location: { cities, country },
});
} else {
await updateJournal({
...entry,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
}
onBack();
} catch (err) {
showToast('Failed to save. Please try again.');
}
}
</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: 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-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>

View File

@@ -0,0 +1,429 @@
<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';
import airplaneImg from '../../../assets/airplane.png';
import trainImg from '../../../assets/train.png';
import busImg from '../../../assets/bus.png';
import carImg from '../../../assets/car.png';
import shipImg from '../../../assets/ship.png';
import walkImg from '../../../assets/walk.png';
/**
* entry = null → "new entry" mode
* entry = {...} → "edit" mode
* @type {{ entry?: import('../shared/types.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 step = $state(1); // 1 | 2 | 3
let errors = $state({
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
});
function clearErrors() {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
}
const transportOptions = [
{ value: 'flight', label: 'Flight', img: airplaneImg },
{ value: 'train', label: 'Train', img: trainImg },
{ value: 'bus', label: 'Bus', img: busImg },
{ value: 'car', label: 'Car', img: carImg },
{ value: 'ship', label: 'Ship', img: shipImg },
{ value: 'walk', label: 'Walk', img: walkImg },
];
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) {
memo = words.slice(0, MEMO_MAX).join(' ');
e.currentTarget.value = memo;
} else {
memo = raw;
}
}
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);
}
function nextStep() {
if (step === 1) {
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;
}
step++;
}
function prevStep() {
if (step === 1) onBack();
else step--;
}
async function save() {
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="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save}>Save changes</button>
{/if}
</div>
</header>
<div class="scroll">
<div class="form">
{#if step === 1}
<h1 class="page-headline">
{isNew ? 'Journal your trip!' : 'Edit your trip'}
</h1>
<div class="row">
<div class="field">
<label class="label" for="edit-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="edit-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
{#each cities as c}
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
{/each}
</div>
{/if}
</div>
</div>
<div class="row">
<div class="field">
<label class="label" for="edit-date">When did you <span class="kw">arrive</span>? <span class="req">*</span></label>
<input id="edit-date" class="input" type="date" bind:value={date} required />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="edit-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="edit-tripType" value={t} bind:group={tripType} />
{t === 'solo' ? '🧑 Solo' : t === 'friends' ? '👥 With friends' : '👨‍👩‍👧‍👦 With family'}
</label>
{/each}
</div>
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
</div>
<div class="field">
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
<input type="radio" name="edit-transport" value={opt.value} bind:group={transport} />
<img src={opt.img} alt={opt.label} class="transport-img" />
{opt.label}
</label>
{/each}
</div>
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
</div>
{:else if step === 2}
<h2 class="step-title">Photos</h2>
<p class="step-sub">Optional — add or update photos from your trip</p>
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
{:else}
<h2 class="step-title">How was it?</h2>
<p class="step-sub">Optional — write a note about your trip</p>
<div class="field">
<div class="label-row">
<label class="label" for="edit-memo">Your notes</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="8" value={memo} oninput={onMemoInput}></textarea>
</div>
{/if}
</div>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
font-family: var(--sans);
}
.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;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active { background: var(--accent); transform: scale(1.25); }
.step-dot.done { background: var(--accent); opacity: 0.35; }
.ghost-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;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
font-family: var(--sans);
font-size: 15px;
font-weight: 400;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 10px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.scroll { flex: 1; overflow-y: auto; }
.form {
max-width: 560px;
margin: 0 auto;
padding: 36px 48px 80px;
display: flex;
flex-direction: column;
gap: 18px;
}
.page-headline {
font-size: 28px;
font-weight: 500;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0 0 4px;
}
.step-title {
font-size: 20px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.step-sub {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
margin: -10px 0 4px;
}
.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-h);
}
.req { color: var(--accent); font-size: 11px; }
.kw { color: var(--accent); }
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
.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; }
.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%;
box-sizing: border-box;
}
.input:focus { border-color: var(--accent-border); }
.textarea { resize: vertical; line-height: 1.6; }
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; }
.toggle-opt {
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 14px; font-weight: 400; color: var(--text);
padding: 12px 14px; border-radius: 10px;
border: 1px solid var(--border);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
background: var(--bg-subtle);
white-space: nowrap;
}
.toggle-opt input { display: none; }
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.transport-opt { flex-direction: column; gap: 6px; padding: 16px 10px; }
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.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;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
</style>

View File

@@ -1,15 +1,15 @@
<script>
import { removeJournal } from '../stores/journalStore.js';
import { flagEmoji } from '../shared/countries.js';
import { removeEntry } from '../../stores/entriesStore.svelte.js';
import { flagEmoji } from '../../shared/countries.js';
import DeleteConfirm from './DeleteConfirm.svelte';
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
/** @type {{ entry: import('../shared/types.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
let { entry, onBack, onEdit } = $props();
let showDeleteConfirm = $state(false);
function handleDelete() {
removeJournal(entry.id);
removeEntry(entry.id);
onBack();
}
@@ -149,7 +149,7 @@
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 52px;
height: 60px;
flex-shrink: 0;
background: var(--bg);
border-bottom: 1px solid var(--border);
@@ -196,13 +196,13 @@
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
font-size: 15px;
font-weight: 400;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 12px;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;

View File

@@ -1,5 +1,5 @@
<script>
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[] }} */
/** @type {{ entries: import('../shared/types.js').JournalEntry[] }} */
let { entries } = $props();
let stats = $derived.by(() => {

View File

@@ -1,18 +1,27 @@
<script>
import { journals, addJournal } from '../../stores/journalStore.js';
import { get } from 'svelte/store';
import { journals, addJournal } from '../stores/journalStore.js';
import { countryNames } from '../shared/countries.js';
import SearchInput from '../shared/SearchInput.svelte';
import { flashCountry } from '../../layout/selection.svelte.js';
import { countryNames } from '../../shared/countries.js';
import { countryCities } from '../../shared/countryCities.js';
import SearchInput from '../../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
import airplaneImg from '../../assets/airplane.png';
import trainImg from '../../assets/train.png';
import busImg from '../../assets/bus.png';
import carImg from '../../assets/car.png';
import shipImg from '../../assets/ship.png';
import walkImg from '../../assets/walk.png';
import airplaneImg from '../../../assets/airplane.png';
import trainImg from '../../../assets/train.png';
import busImg from '../../../assets/bus.png';
import carImg from '../../../assets/car.png';
import shipImg from '../../../assets/ship.png';
import walkImg from '../../../assets/walk.png';
let { initialCountry = '', onBack, onSaved = onBack } = $props();
// ── Journal store (reactive) ────────────────────────────────────────
let journalEntries = $state(get(journals));
$effect(() => {
const unsub = journals.subscribe(v => { journalEntries = v; });
return unsub;
});
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
@@ -55,8 +64,11 @@
// otherwise show all known cities.
let cityOptions = $derived(
country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
? [...new Set([
...(countryCities[country.trim()] ?? []),
...journalEntries.filter(j => (j.location?.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location?.cities ?? []),
])]
: []
);
function addCity(val) {
@@ -106,9 +118,11 @@
// ── Save ───────────────────────────────────────────────────────────
let saving = $state(false);
let saveError = $state('');
async function save() {
saving = true;
saveError = '';
const memo = questions
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
.filter(Boolean)
@@ -124,9 +138,11 @@
photos,
location: { cities, country },
});
flashCountry(country);
onSaved();
} catch {
} catch (e) {
saving = false;
saveError = e?.message ?? 'Failed to save. Please try again.';
}
}
</script>
@@ -153,6 +169,7 @@
<button class="save-btn" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save trip'}
</button>
{#if saveError}<span class="save-err">{saveError}</span>{/if}
{/if}
</div>
</header>
@@ -161,18 +178,24 @@
<div class="form">
{#if step === 1}
<!-- ── STEP 1: Details ── -->
<h2 class="step-title">Trip details</h2>
<!-- headline -->
<h1 class="page-headline">
{#if country.trim()}
Journal your trip to <strong>{country}</strong>!
{:else}
Journal your trip!
{/if}
</h1>
<div class="row">
<div class="field">
<label class="label" for="nc-country">Country <span class="req">*</span></label>
<label class="label" for="nc-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} onblurcommit={addCity} />
<label class="label" for="nc-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
@@ -186,19 +209,19 @@
<div class="row">
<div class="field">
<label class="label" for="nc-date">Date <span class="req">*</span></label>
<label class="label" for="nc-date">When did you <span class="kw">arrive</span>? <span class="req">*</span></label>
<input id="nc-date" class="input" type="date" bind:value={date} />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-days">Days <span class="req">*</span></label>
<label class="label" for="nc-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label">Trip type <span class="req">*</span></label>
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
@@ -211,13 +234,13 @@
</div>
<div class="field">
<label class="label">How did you get there? <span class="req">*</span></label>
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="transport-opt" class:active={transport === opt.value}>
<label class="toggle-opt" class:active={transport === opt.value}>
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
<img src={opt.img} alt={opt.label} class="transport-img" />
<span class="transport-label">{opt.label}</span>
{opt.label}
</label>
{/each}
</div>
@@ -232,7 +255,12 @@
{:else}
<!-- ── STEP 3: Questions ── -->
<h2 class="step-title">Your memories</h2>
<h2 class="step-title">
Your memories{cities.length > 0 ? ` of ${cities.join(', ')}` : country.trim() ? ` of ${country}` : ''}
</h2>
{#if cities.length > 0 || country.trim()}
<p class="step-sub">{cities.join(', ')}{cities.length > 0 && country.trim() ? `, ${country}` : country.trim()}</p>
{/if}
{#each questions as q, i}
<div class="q-card">
@@ -299,13 +327,13 @@
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
font-size: 15px;
font-weight: 400;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 10px;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
@@ -313,13 +341,13 @@
.save-btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
font-size: 15px;
font-weight: 400;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 7px 14px;
border-radius: 10px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
@@ -346,6 +374,14 @@
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.page-headline {
font-size: 28px;
font-weight: 500;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0 0 4px;
}
.page-headline strong { font-weight: 600; }
.step-sub {
font-size: 13px;
font-weight: 300;
@@ -366,27 +402,9 @@
color: var(--text-sub);
}
.req { color: var(--accent); font-size: 11px; }
.kw { color: var(--accent); }
.ferr { font-size: 11px; color: #dc2626; }
.combo-select, .city-text {
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%;
box-sizing: border-box;
display: block;
}
.combo-select:focus, .city-text:focus { border-color: var(--accent-border); }
.combo-select { margin-bottom: 6px; cursor: pointer; }
.city-text { margin-top: 0; }
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
.input {
font-family: var(--sans);
@@ -404,33 +422,23 @@
}
.input:focus { border-color: var(--accent-border); }
.toggle-row { display: flex; gap: 8px; }
.toggle-row { display: flex; gap: 8px; flex-wrap: wrap; }
.toggle-opt {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 300; color: var(--text);
padding: 7px 14px; border-radius: 8px;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
font-size: 14px; font-weight: 400; color: var(--text);
padding: 16px 10px; border-radius: 10px;
border: 1px solid var(--border);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
background: var(--bg-subtle);
white-space: nowrap;
flex: 1;
}
.toggle-opt input { display: none; }
.toggle-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.transport-opt {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; aspect-ratio: 1;
border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle);
cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.transport-opt input { display: none; }
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); }
.transport-img { width: 60px; height: 60px; object-fit: contain; }
.transport-label {
font-size: 12px; font-weight: 300; color: var(--text-sub);
letter-spacing: 0.02em;
}
.transport-opt.active .transport-label { color: var(--accent); }
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.tag {
@@ -449,11 +457,12 @@
.q-card {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
gap: 14px;
background: var(--bg);
border: 1.5px solid var(--accent-border);
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
}
.q-text {
font-size: 14px;
@@ -465,10 +474,10 @@
}
.q-input {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
font-size: 16px;
font-weight: 400;
color: var(--text-h);
background: var(--bg);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;

View File

@@ -1,5 +1,5 @@
<script>
import { storage } from '../firebase.js';
import { storage } from '../../firebase.js';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
@@ -7,18 +7,23 @@
let fileInput;
let uploading = $state(false);
let uploadError = $state('');
function remove(index) {
onchange(photos.filter((_, i) => i !== index));
uploadError = '';
}
async function addFiles(e) {
const files = Array.from(e.currentTarget.files ?? []);
if (!files.length) return;
uploading = true;
uploadError = '';
try {
const urls = await Promise.all(files.map(uploadPhoto));
onchange([...photos, ...urls]);
} catch (err) {
uploadError = err?.message ?? 'Upload failed. Check Firebase Storage rules.';
} finally {
uploading = false;
e.currentTarget.value = '';
@@ -68,6 +73,10 @@
</button>
</div>
{/if}
{#if uploadError}
<div class="upload-error">{uploadError}</div>
{/if}
</div>
<style>
@@ -183,4 +192,15 @@
transition: border-color 0.15s, color 0.15s;
}
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
.upload-error {
font-size: 12px;
color: #ef4444;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 8px 10px;
line-height: 1.4;
word-break: break-word;
}
</style>

View File

@@ -1,7 +1,9 @@
<script>
import { toPng } from 'html-to-image';
import { getTotalCount } from '../../layout/selection.svelte.js';
import profileImg from '../../../assets/profile.png';
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClose: () => void }} */
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClose: () => void }} */
let { entries, onClose } = $props();
let cardEl = $state(null);
@@ -85,11 +87,59 @@
};
});
async function getFontDataUrl() {
// Get the actual woff2 URL the browser resolved for Bricolage Grotesque
for (const font of document.fonts) {
if (font.family.includes('Bricolage')) {
// font.status === 'loaded' means the browser has it
await font.load();
}
}
// Fetch the Google Fonts CSS to extract the real woff2 URL
const cssRes = await fetch(
'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500&display=swap',
{ headers: { 'User-Agent': 'Mozilla/5.0' } }
);
const css = await cssRes.text();
const match = css.match(/url\((https:\/\/fonts\.gstatic\.com[^)]+\.woff2)\)/);
if (!match) return null;
const fontRes = await fetch(match[1]);
const blob = await fontRes.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
async function download() {
if (!cardEl) return;
downloading = true;
try {
const dataUrl = await toPng(cardEl, { pixelRatio: 3 });
await document.fonts.ready;
let fontFaceRule = '';
try {
const fontData = await getFontDataUrl();
if (fontData) {
fontFaceRule = `@font-face { font-family: 'Bricolage Grotesque'; src: url('${fontData}') format('woff2'); font-weight: 100 900; font-style: normal; }`;
}
} catch {}
// Inject font as a real <style> inside the card so html-to-image clones it
let injected = null;
if (fontFaceRule) {
injected = document.createElement('style');
injected.textContent = fontFaceRule;
cardEl.prepend(injected);
}
const opts = { pixelRatio: 3 };
await toPng(cardEl, opts); // first pass loads resources
const dataUrl = await toPng(cardEl, opts);
if (injected) injected.remove();
const a = document.createElement('a');
a.download = 'my-journey.png';
a.href = dataUrl;
@@ -135,35 +185,22 @@
<!-- Header -->
<div class="card-header">
<span class="card-brand">MAP JOURNAL</span>
<span class="card-brand">JOURNI</span>
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
</div>
<!-- Profile -->
<div class="profile-wrap">
<img class="profile-img" src={profileImg} alt="profile" />
</div>
<!-- Hero stat -->
<div class="hero">
<p class="hero-num">{stats.totalDays}</p>
<p class="hero-label">days of travel</p>
<p class="hero-pre">You've colored</p>
<p class="big-num">{getTotalCount() > 0 ? Math.round((stats.countries.length / getTotalCount()) * 100) : 0}%</p>
<p class="hero-post">of the world map.</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">
@@ -197,29 +234,10 @@
</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>
<span>journi</span>
</div>
</div>
@@ -352,25 +370,41 @@
letter-spacing: 0.06em;
}
/* Profile */
.profile-wrap {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.profile-img {
width: 192px;
height: 192px;
border-radius: 50%;
object-fit: cover;
}
/* 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 {
.hero-pre, .hero-post {
font-size: 16px;
font-weight: 300;
color: #a5b4fc;
color: rgba(255,255,255,0.6);
letter-spacing: 0.04em;
margin-top: 4px;
margin: 0;
}
.big-num {
font-size: 88px;
font-weight: 500;
color: #fff;
letter-spacing: -4px;
line-height: 1;
margin: 8px 0;
}
/* Stat grid */
@@ -388,6 +422,10 @@
border-radius: 10px;
padding: 10px 8px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stat-num {
font-size: 22px;

View File

@@ -1,5 +1,5 @@
<script>
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClick: () => void }} */
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClick: () => void }} */
let { entries, onClick } = $props();
const continentMap = {
@@ -42,10 +42,6 @@
<div class="pc-header">
<span class="pc-brand">MAP JOURNAL</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pc-share-icon">
<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>
</div>
<div class="pc-hero">
@@ -83,7 +79,10 @@
</div>
{/if}
<div class="pc-cta">Share your journey →</div>
<svg class="pc-share-icon-corner" 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="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>
</button>
@@ -143,7 +142,13 @@
letter-spacing: 0.2em;
color: #a5b4fc;
}
.pc-share-icon { color: #a5b4fc; flex-shrink: 0; }
.pc-share-icon-corner {
position: absolute;
bottom: 14px;
right: 14px;
color: #a5b4fc;
z-index: 1;
}
.pc-hero {
position: relative;
@@ -231,5 +236,6 @@
letter-spacing: 0.04em;
padding-top: 4px;
border-top: 1px solid rgba(255,255,255,0.08);
text-align: right;
}
</style>

View File

@@ -1,16 +1,28 @@
<script>
import { flagEmoji } from '../shared/countries.js';
import { flagEmoji } from '../../shared/countries.js';
import default1 from '../../../assets/default-1.jpeg';
import default2 from '../../../assets/default-2.jpeg';
import default3 from '../../../assets/default-3.jpeg';
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onClick: () => void }} */
/** @type {{ entry: import('../shared/types.js').JournalEntry, onClick: () => void }} */
let { entry, onClick } = $props();
const defaults = [default1, default2, default3];
function formatDate(/** @type {string} */ iso) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
});
}
let mainPhoto = $derived(entry.photos[0] ?? null);
// Pick a stable random default based on the entry id
function defaultPhoto(id) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return defaults[hash % defaults.length];
}
let mainPhoto = $derived(entry.photos[0] ?? defaultPhoto(entry.id));
let thumbPhotos = $derived(entry.photos.slice(1, 4));
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
@@ -29,10 +41,11 @@
<div class="v-dot" aria-hidden="true"></div>
<div class="v-content">
<!-- Country above card -->
<!-- Country + cities above card -->
<div class="above-card">
<span class="flag">{flagEmoji(entry.location.country)}</span>
<span class="country-name">{entry.location.country}</span>
<span class="city-inline">· {entry.location.cities.join(', ')}</span>
</div>
<!-- Card -->
@@ -40,36 +53,10 @@
onclick={onClick}
onkeydown={(e) => e.key === 'Enter' && onClick()}>
<!-- Trip badge — top-right of card, outside photo -->
<span class="trip-badge trip-badge--{entry.tripType}">
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
</span>
<!-- Photos -->
<div class="photo-grid" class:has-thumbs={thumbPhotos.length > 0}>
<div class="photo-main">
{#if mainPhoto}
<img src={mainPhoto} alt="" loading="lazy"
onerror={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling.style.display = 'flex';
}} />
<div class="photo-fallback" style="display:none">
<svg width="24" height="24" 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>
</div>
{:else}
<div class="photo-fallback">
<svg width="24" height="24" 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>
</div>
{/if}
<img src={mainPhoto} alt="" loading="lazy" />
</div>
{#if thumbPhotos.length > 0}
@@ -99,18 +86,17 @@
<!-- Info bar -->
<div class="card-info">
<span class="city">{entry.location.cities.join(', ')}</span>
<span class="days-label">{entry.days} {entry.days === 1 ? 'day' : 'days'}</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>
<span class="trip-badge trip-badge--{entry.tripType}">
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
</span>
</div>
</div>
@@ -172,6 +158,14 @@
color: var(--text-h);
letter-spacing: -0.2px;
}
.city-inline {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Card ── */
.entry-card {
@@ -191,22 +185,18 @@
transform: translateY(-2px);
}
/* ── Trip badge — absolute top-right of card ── */
/* ── Trip badge — inline in info bar ── */
.trip-badge {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
font-size: 11px;
font-weight: 300;
padding: 3px 10px;
font-weight: 400;
padding: 2px 8px;
border-radius: 20px;
letter-spacing: 0.04em;
backdrop-filter: blur(6px);
letter-spacing: 0.03em;
white-space: nowrap;
}
.trip-badge--solo { background: rgba(245,158,11,0.85); color: #fff; }
.trip-badge--friends { background: rgba(124,58,237,0.85); color: #fff; }
.trip-badge--family { background: rgba(16,185,129,0.85); color: #fff; }
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; border: 1px solid rgba(245,158,11,0.25); }
.trip-badge--friends { background: rgba(124,58,237,0.07); color: #7c3aed; border: 1px solid rgba(124,58,237,0.2); }
.trip-badge--family { background: rgba(16,185,129,0.08); color: #059669; border: 1px solid rgba(16,185,129,0.2); }
/* ── Photo grid — fixed height, always consistent ── */
.photo-grid {
@@ -294,16 +284,12 @@
padding: 10px 14px;
background: var(--bg);
border-top: 1px solid var(--border);
gap: 8px;
min-height: 44px;
}
.city {
font-size: 13px;
.days-label {
font-size: 12px;
font-weight: 300;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-sub);
}
.meta {
display: flex;

View File

@@ -1,11 +1,10 @@
<script>
import { get } from 'svelte/store';
import { journals } from '../stores/journalStore.js';
import { getEntries } from '../../stores/entriesStore.svelte.js';
import TimelineToolbar from './TimelineToolbar.svelte';
import TimelineCard from './TimelineCard.svelte';
import JournalDetail from './JournalDetail.svelte';
import EditForm from './EditForm.svelte';
import NewEntryForm from './NewEntryForm.svelte';
import JournalDetail from '../detail/JournalDetail.svelte';
import EditForm from '../detail/EditForm.svelte';
import NewEntryForm from '../detail/NewEntryForm.svelte';
import ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte';
@@ -26,11 +25,7 @@
});
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
let entries = $state(get(journals));
$effect(() => {
const unsub = journals.subscribe((v) => { entries = v; });
return unsub;
});
let entries = $derived(getEntries());
let sortKey = $state('date-desc');
@@ -70,53 +65,55 @@
/>
</div>
{:else}
<div class="right-panel">
<div class="center-col">
<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>
Add trip
</button>
<div class="list-view">
<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>
Add trip
</button>
</div>
<div class="two-col">
<div class="left-col">
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}
<div class="sort-row">
<span class="sort-label">Sort by</span>
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A Z</option>
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z A</option>
</select>
</div>
<ol class="v-list">
{#each sortedEntries as entry, i (entry.id)}
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
<li class="year-marker" aria-hidden="true">
<span class="year-label">{getYear(entry.date)}</span>
</li>
{/if}
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
{/each}
</ol>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
</footer>
</div>
{#if sortedEntries.length > 0}
<div class="share-row">
<div class="right-col">
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
</div>
{/if}
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}
<div class="sort-row">
<span class="sort-label">Sort by</span>
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A Z</option>
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z A</option>
</select>
</div>
<ol class="v-list">
{#each sortedEntries as entry, i (entry.id)}
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
<li class="year-marker" aria-hidden="true">
<span class="year-label">{getYear(entry.date)}</span>
</li>
{/if}
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
{/each}
</ol>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
</footer>
</div>
</div>
{/if}
@@ -137,31 +134,51 @@
overflow: hidden;
}
/* ── Right panel ── */
.right-panel {
/* ── List view wrapper (scrollable) ── */
.list-view {
flex: 1;
overflow-y: auto;
padding: 48px 0 80px;
box-sizing: border-box;
min-width: 0;
}
.page-header,
.two-col {
max-width: 960px;
margin-left: auto;
margin-right: auto;
padding-left: 48px;
padding-right: 48px;
}
/* ── Two-column below header ── */
.two-col {
display: flex;
flex-direction: row;
gap: 32px;
align-items: flex-start;
margin-top: 24px;
}
.left-col {
flex: 1;
min-width: 0;
overflow-y: auto;
background: var(--bg);
}
/* ── Centered single column ── */
.center-col {
max-width: 680px;
width: 100%;
margin: 0 auto;
padding: 48px 48px 80px;
box-sizing: border-box;
.right-col {
width: 260px;
flex-shrink: 0;
position: sticky;
top: 0;
}
.share-row {
margin-bottom: 24px;
@media (max-width: 900px) {
.right-col { display: none; }
}
@media (max-width: 760px) {
.center-col {
padding: 32px 24px 60px;
}
.list-view { padding: 32px 24px 60px; }
}
/* ── Detail view ── */

View File

@@ -1,391 +0,0 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
let { onclose, onprogress } = $props();
const HOME_CODE = '203';
const MOCK_TRIPS = [
{ countryName: 'Japan', countryCode: '392', date: '2024-03-15', city: 'Tokyo' },
{ countryName: 'France', countryCode: '191', date: '2024-06-20', city: 'Paris' },
{ countryName: 'Spain', countryCode: '724', date: '2024-09-10', city: 'Barcelona' },
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' },
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' },
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' },
];
const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e';
const ARC_COLOR = '#000000';
const PLANE_COLOR = '#7c3aed';
const PLANE_PATH = 'M14,0 L4,-3 L0,-7 L-3,-5 L0,-2 L-5,-1 L-9,-5 L-11,-4 L-7,0 L-11,4 L-9,5 L-5,1 L0,2 L-3,5 L0,7 L4,3 Z';
const UNVISITED = '#ffffff';
const TERRITORY_PARENT = {
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
'258': '250', '260': '250', '304': '208', '316': '840', '334': '036',
'446': '156', '500': '826', '531': '528', '533': '528', '534': '528',
'540': '250', '570': '554', '574': '036', '580': '840', '612': '826',
'630': '840', '652': '250', '654': '826', '660': '826', '663': '250',
'666': '250', '796': '826', '831': '826', '832': '826', '833': '826',
'850': '840', '876': '250',
};
function effId(d) {
return TERRITORY_PARENT[d.id] || d.id;
}
let frameEl;
let svg, g, pathFn, projection;
let countryPaths;
let homeFeature;
let featuresById = {};
let isCancelled = false;
let isPlaying = $state(false);
let isFinished = $state(false);
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
const s = proj.scale() * 1.5;
proj.scale(s).translate([w / 2, h * 0.70]);
}
function computeArc(p1, p2) {
const interp = d3.geoInterpolate(p1, p2);
const steps = 80;
const raw = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const geo = interp(t);
const pt = projection(geo);
if (!pt) continue;
raw.push({ t, x: pt[0], y: pt[1] });
}
if (raw.length < 2) return [];
const first = raw[0];
const last = raw[raw.length - 1];
const dx = last.x - first.x;
const dy = last.y - first.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const arcH = Math.max(40, Math.min(200, dist * 0.22));
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
}
function getAngleAtLength(node, len) {
const d = 0.5;
const total = node.getTotalLength();
const p1 = node.getPointAtLength(Math.max(0, len - d));
const p2 = node.getPointAtLength(Math.min(total, len + d));
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
}
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration) {
return new Promise((resolve) => {
const node = pathEl.node();
if (!node) { resolve(); return; }
const totalLength = node.getTotalLength();
if (totalLength === 0) { resolve(); return; }
d3.timer(elapsed => {
if (isCancelled) { resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const offset = startOffset + (endOffset - startOffset) * t;
pathEl.attr('stroke-dashoffset', offset);
const drawn = totalLength - offset;
const clamped = Math.max(0, Math.min(drawn, totalLength));
try {
const pt = node.getPointAtLength(clamped);
const angle = getAngleAtLength(node, clamped);
tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1);
} catch (e) {
// ignore SVG errors
}
if (t >= 1) {
resolve();
return true;
}
});
});
}
function delay(ms) {
return new Promise(resolve => {
if (isCancelled) { resolve(); return; }
const id = setTimeout(resolve, ms);
});
}
async function animateTrip(destCode, destFeature) {
if (!homeFeature || !destFeature) return;
const homeCentroid = d3.geoCentroid(homeFeature);
const destCentroid = d3.geoCentroid(destFeature);
const pts = computeArc(homeCentroid, destCentroid);
if (pts.length < 2) return;
const lineGen = d3.line().curve(d3.curveBasis);
const pathData = lineGen(pts);
if (!pathData) return;
function createArc(pathData) {
const el = g.append('path')
.attr('d', pathData)
.attr('fill', 'none')
.attr('stroke', ARC_COLOR)
.attr('stroke-width', 2.5)
.attr('stroke-opacity', 0.8)
.attr('stroke-linecap', 'round');
const tip = g.append('path')
.attr('d', PLANE_PATH)
.attr('fill', PLANE_COLOR)
.attr('opacity', 0);
return { el, tip };
}
// --- Outbound: home -> dest ---
let { el: outEl, tip: outTip } = createArc(pathData);
const outLen = outEl.node().getTotalLength();
outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen);
const homeDot = g.append('circle')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', pts[0][0])
.attr('cy', pts[0][1])
.attr('opacity', 1);
await animateStroke(outEl, outTip, outLen, 0, 2500);
if (isCancelled) return;
outEl.remove();
outTip.remove();
homeDot.remove();
// Color the destination country
const targetPath = countryPaths.filter(d => effId(d) === destCode);
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
g.selectAll('.micro-state-j')
.filter(d => effId(d) === destCode)
.transition().duration(500)
.attr('fill', VISITED_COLOR);
await delay(800);
if (isCancelled) return;
// --- Return: dest -> home ---
const revPts = [...pts].reverse();
const revData = d3.line().curve(d3.curveBasis)(revPts);
let { el: retEl, tip: retTip } = createArc(revData);
const retLen = retEl.node().getTotalLength();
retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen);
const destDot = g.append('circle')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', revPts[0][0])
.attr('cy', revPts[0][1])
.attr('opacity', 1);
await animateStroke(retEl, retTip, retLen, 0, 2200);
if (isCancelled) return;
retEl.remove();
retTip.remove();
destDot.remove();
await delay(300);
}
async function startJourney() {
isPlaying = true;
isFinished = false;
isCancelled = false;
const trips = MOCK_TRIPS;
const total = trips.length;
for (let i = 0; i < total; i++) {
if (isCancelled) break;
const trip = trips[i];
const destFeature = featuresById[trip.countryCode];
if (!destFeature) continue;
const label = `${trip.city}, ${trip.countryName}`;
if (onprogress) onprogress({ index: i + 1, total, label });
await animateTrip(trip.countryCode, destFeature);
}
if (!isCancelled) {
isFinished = true;
isPlaying = false;
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
} else {
isPlaying = false;
}
}
function stopJourney() {
isCancelled = true;
isPlaying = false;
}
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
projection = d3.geoMercator();
fitProjection(projection, width, height);
pathFn = d3.geoPath().projection(projection);
const countries = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countries.forEach(f => {
if (!f.id) f.id = 'XK';
});
for (const f of countries) {
featuresById[effId(f)] = f;
}
homeFeature = featuresById[HOME_CODE];
svg = d3.select(frameEl)
.append('svg')
.attr('width', width)
.attr('height', height)
.style('cursor', 'default');
g = svg.append('g');
countryPaths = g.selectAll('path')
.data(countries)
.join('path')
.attr('d', pathFn)
.attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5);
function renderMicrostates() {
g.selectAll('.micro-state-j').remove();
const threshold = 4;
countryPaths.each(function (d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = pathFn.centroid(d);
g.append('circle')
.attr('class', 'micro-state-j')
.datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 2)
.attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
}
});
}
renderMicrostates();
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height);
countryPaths.attr('d', pathFn);
renderMicrostates();
}
});
observer.observe(frameEl);
startJourney();
return () => {
stopJourney();
observer.disconnect();
if (svg) svg.remove();
};
});
</script>
<div bind:this={frameEl} class="journey-frame">
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
{#if isFinished}
<div class="done-badge">Journey complete!</div>
{/if}
</div>
<style>
.journey-frame {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: #a4c8e0;
}
.journey-frame :global(svg) {
display: block;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.close-btn:hover {
background: rgba(0,0,0,0.75);
}
.done-badge {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0,0,0,0.65);
color: #fff;
font-family: var(--heading, sans-serif);
font-size: 16px;
padding: 10px 24px;
border-radius: 24px;
white-space: nowrap;
letter-spacing: 0.04em;
}
</style>

View File

@@ -5,31 +5,17 @@
import worldData from 'world-atlas/countries-50m.json';
import { get } from 'svelte/store';
import { journals } from '../stores/journalStore.js';
import airplaneImg from '../../assets/airplane.png';
import trainImg from '../../assets/train.png';
import busImg from '../../assets/bus.png';
import carImg from '../../assets/car.png';
import shipImg from '../../assets/ship.png';
import walkImg from '../../assets/walk.png';
import airplaneImg from '../../assets/airplane-animation.png';
let { onclose, onprogress } = $props();
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
const HOME_CODE = '203';
const TRANSPORT_IMG = {
flight: airplaneImg,
train: trainImg,
bus: busImg,
car: carImg,
ship: shipImg,
walk: walkImg,
};
const PLANE_SIZE = 26;
const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e';
const ARC_COLOR = '#000000';
const PLANE_COLOR = '#7c3aed';
const PLANE_PATH = 'M14,0 L4,-3 L0,-7 L-3,-5 L0,-2 L-5,-1 L-9,-5 L-11,-4 L-7,0 L-11,4 L-9,5 L-5,1 L0,2 L-3,5 L0,7 L4,3 Z';
const ARC_COLOR = '#666666';
const UNVISITED = '#ffffff';
const TERRITORY_PARENT = {
@@ -43,393 +29,391 @@
'850': '840', '876': '250',
};
function effId(d) {
return TERRITORY_PARENT[d.id] || d.id;
}
function effId(d) { return TERRITORY_PARENT[d.id] || d.id; }
let frameEl;
let svg, g, pathFn, projection;
let svg, gBase, gCountries, gAnim, pathFn, projection;
let countryPaths;
let homeFeature;
let featuresById = {};
let countriesData = [];
let isCancelled = false;
let isPlaying = $state(false);
let isFinished = $state(false);
let visitedCodes = new Set();
let animId = 0;
let currentDateLabel = $state('');
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
const s = proj.scale() * 1.5;
proj.scale(s).translate([w / 2, h * 0.70]);
function formatDateLabel(dateStr) {
const d = new Date(dateStr);
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return `${months[d.getMonth()]} ${d.getFullYear()}`;
}
function computeArc(p1, p2) {
const interp = d3.geoInterpolate(p1, p2);
const steps = 80;
const raw = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const geo = interp(t);
const pt = projection(geo);
for (let i = 0; i <= 80; i++) {
const t = i / 80;
const pt = projection(interp(t));
if (!pt) continue;
raw.push({ t, x: pt[0], y: pt[1] });
}
if (raw.length < 2) return [];
const first = raw[0];
const last = raw[raw.length - 1];
const dx = last.x - first.x;
const dy = last.y - first.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const first = raw[0], last = raw[raw.length - 1];
const dist = Math.sqrt((last.x-first.x)**2 + (last.y-first.y)**2);
const arcH = Math.max(40, Math.min(200, dist * 0.22));
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
}
function getAngleAtLength(node, len) {
const d = 0.5;
const total = node.getTotalLength();
const p1 = node.getPointAtLength(Math.max(0, len - d));
const p2 = node.getPointAtLength(Math.min(total, len + d));
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
function planeTransform(x, y, angle, flip) {
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
}
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration) {
return new Promise((resolve) => {
const node = pathEl.node();
if (!node) { resolve(); return; }
const totalLength = node.getTotalLength();
function delay(ms) {
return new Promise(resolve => { if (isCancelled) { resolve(); return; } setTimeout(resolve, ms); });
}
if (totalLength === 0) { resolve(); return; }
function setupProjection(width, height) {
if (mode === 'map') {
projection = d3.geoMercator();
projection.fitSize([width, height], { type: 'Sphere' });
const s = projection.scale() * 1.5;
projection.scale(s).translate([width / 2, height * 0.70]);
} else {
const size = Math.min(width, height) * 0.92;
projection = d3.geoOrthographic()
.rotate([0, 0])
.fitSize([size, size], { type: 'Sphere' })
.translate([width / 2, height / 2]);
}
pathFn = d3.geoPath().projection(projection);
}
d3.timer(elapsed => {
if (isCancelled) { resolve(); return true; }
function renderMap() {
gBase.selectAll('*').remove();
gCountries.selectAll('*').remove();
gAnim.selectAll('*').remove();
const fillFn = d => {
const id = effId(d);
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
if (visitedCodes.has(id)) return VISITED_COLOR;
return UNVISITED;
};
if (mode === 'globe') {
gBase.append('path').attr('class', 'sphere').datum({ type: 'Sphere' })
.attr('d', pathFn).attr('fill', '#a4c8e0').attr('stroke', '#8b9bb0').attr('stroke-width', 1.5);
}
countryPaths = gCountries.selectAll('path')
.data(countriesData, d => effId(d)).join('path')
.attr('d', pathFn).attr('fill', fillFn)
.attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4')
.attr('stroke-width', mode === 'globe' ? 0.3 : 0.5);
if (mode === 'map') renderMicrostates();
}
function renderMicrostates() {
gBase.selectAll('.micro-state-j').remove();
const fillFn = d => {
const id = effId(d);
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
if (visitedCodes.has(id)) return VISITED_COLOR;
return UNVISITED;
};
countryPaths.each(function(d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < 4 && height < 4) {
const [cx, cy] = pathFn.centroid(d);
gBase.append('circle').attr('class', 'micro-state-j').datum(d)
.attr('cx', cx).attr('cy', cy).attr('r', 2).attr('fill', fillFn(d))
.attr('stroke', '#94a3b8').attr('stroke-width', 0.5);
}
});
}
function redrawBase() {
countryPaths.attr('d', pathFn);
if (mode === 'globe') gBase.select('.sphere').attr('d', pathFn);
}
function rotateGlobeTo(lon, lat, duration = 1500) {
return new Promise(resolve => {
if (isCancelled) { resolve(); return; }
const current = projection.rotate();
const interp = d3.geoInterpolate([-current[0], -current[1]], [lon, lat]);
const timer = d3.timer(elapsed => {
if (isCancelled) { timer.stop(); resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const offset = startOffset + (endOffset - startOffset) * t;
pathEl.attr('stroke-dashoffset', offset);
const drawn = totalLength - offset;
const clamped = Math.max(0, Math.min(drawn, totalLength));
try {
const pt = node.getPointAtLength(clamped);
const angle = getAngleAtLength(node, clamped);
tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1);
} catch (e) {
// ignore SVG errors
}
if (t >= 1) {
resolve();
return true;
}
const point = interp(t);
projection.rotate([-point[0], -point[1]]);
redrawBase();
if (t >= 1) { timer.stop(); resolve(); return true; }
});
});
}
function delay(ms) {
function createArcEl(iconSrc) {
const el = gAnim.append('path')
.attr('fill', 'none').attr('stroke', ARC_COLOR)
.attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
.attr('stroke-linecap', 'round').attr('stroke-dasharray', '10, 6');
const tip = gAnim.append('image')
.attr('href', iconSrc).attr('width', PLANE_SIZE).attr('height', PLANE_SIZE)
.attr('x', -PLANE_SIZE / 2).attr('y', -PLANE_SIZE / 2)
.attr('preserveAspectRatio', 'xMidYMid meet').attr('opacity', 0);
return { el, tip };
}
function animateIncrementalPath(el, tip, pts, duration, flip = false) {
return new Promise(resolve => {
if (isCancelled) { resolve(); return; }
const id = setTimeout(resolve, ms);
const lineGen = d3.line().curve(d3.curveBasis);
d3.timer(elapsed => {
if (isCancelled) { resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const count = Math.max(2, Math.floor(t * (pts.length - 1)) + 1);
const visible = pts.slice(0, count);
if (visible.length >= 2) el.attr('d', lineGen(visible));
if (visible.length > 0) {
const last = visible[visible.length - 1];
let angle = 0;
if (visible.length >= 2) {
const prev = visible[visible.length - 2];
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
}
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
}
if (t >= 1) { resolve(); return true; }
});
});
}
function animateReprojectingArc(el, tip, geoPts, lineGen, duration, flip = false) {
return new Promise(resolve => {
const timer = d3.timer(elapsed => {
if (isCancelled) { timer.stop(); resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const count = Math.max(2, Math.floor(t * (geoPts.length - 1)) + 1);
const screenPts = geoPts.slice(0, count).map(p => projection(p)).filter(Boolean);
if (screenPts.length >= 2) el.attr('d', lineGen(screenPts));
if (screenPts.length > 0) {
const last = screenPts[screenPts.length - 1];
let angle = 0;
if (screenPts.length >= 2) {
const prev = screenPts[screenPts.length - 2];
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
}
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
}
if (t >= 1) { timer.stop(); resolve(); return true; }
});
});
}
async function animateTrip(destCode, destFeature, transport = 'flight') {
if (!homeFeature || !destFeature) return;
const iconSrc = airplaneImg;
const homeCentroid = d3.geoCentroid(homeFeature);
const destCentroid = d3.geoCentroid(destFeature);
if (mode === 'map') {
await animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc);
} else {
await animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc);
}
}
async function animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc) {
const pts = computeArc(homeCentroid, destCentroid);
if (pts.length < 2) return;
const lineGen = d3.line().curve(d3.curveBasis);
const pathData = lineGen(pts);
if (!pathData) return;
const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg;
const iconSize = 28;
const iconHalf = iconSize / 2;
function createArc(pathData) {
const el = g.append('path')
.attr('d', pathData)
.attr('fill', 'none')
.attr('stroke', ARC_COLOR)
.attr('stroke-width', 2.5)
.attr('stroke-opacity', 0.8)
.attr('stroke-linecap', 'round');
const tip = g.append('image')
.attr('href', iconSrc)
.attr('width', iconSize)
.attr('height', iconSize)
.attr('x', -iconHalf)
.attr('y', -iconHalf)
.attr('opacity', 0);
return { el, tip };
}
// --- Outbound: home -> dest ---
let { el: outEl, tip: outTip } = createArc(pathData);
const outLen = outEl.node().getTotalLength();
outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen);
const homeDot = g.append('circle')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', pts[0][0])
.attr('cy', pts[0][1])
.attr('opacity', 1);
await animateStroke(outEl, outTip, outLen, 0, 2500);
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
await animateIncrementalPath(outEl, outTip, pts, 2500, pts[pts.length-1][0] < pts[0][0]);
if (isCancelled) return;
outEl.remove();
outTip.remove();
homeDot.remove();
// Color the destination country
const targetPath = countryPaths.filter(d => effId(d) === destCode);
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
g.selectAll('.micro-state-j')
.filter(d => effId(d) === destCode)
.transition().duration(500)
.attr('fill', VISITED_COLOR);
outEl.remove(); outTip.remove();
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
visitedCodes.add(destCode);
gBase.selectAll('.micro-state-j').filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
await delay(800);
if (isCancelled) return;
// --- Return: dest -> home ---
const revPts = [...pts].reverse();
const revData = d3.line().curve(d3.curveBasis)(revPts);
let { el: retEl, tip: retTip } = createArc(revData);
const retLen = retEl.node().getTotalLength();
retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen);
const destDot = g.append('circle')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', revPts[0][0])
.attr('cy', revPts[0][1])
.attr('opacity', 1);
await animateStroke(retEl, retTip, retLen, 0, 2200);
const { el: retEl, tip: retTip } = createArcEl(iconSrc);
await animateIncrementalPath(retEl, retTip, revPts, 2200, revPts[revPts.length-1][0] < revPts[0][0]);
if (isCancelled) return;
retEl.remove(); retTip.remove();
await delay(300);
}
retEl.remove();
retTip.remove();
destDot.remove();
async function animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc) {
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
const geoPts = Array.from({ length: 81 }, (_, i) => interp(i / 80));
const dur = Math.round(1500 + d3.geoDistance(homeCentroid, destCentroid) * 2500);
const lineGen = d3.line().curve(d3.curveBasis);
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
const outGcs = geoPts.map(p => projection(p)).filter(Boolean);
await Promise.all([
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outGcs.length >= 2 && outGcs[outGcs.length-1][0] < outGcs[0][0]),
]);
if (isCancelled) return;
outEl.remove(); outTip.remove();
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
visitedCodes.add(destCode);
await delay(600);
if (isCancelled) return;
const revGeoPts = [...geoPts].reverse();
const { el: retEl, tip: retTip } = createArcEl(iconSrc);
const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean);
await Promise.all([
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retGcs.length >= 2 && retGcs[retGcs.length-1][0] < retGcs[0][0]),
]);
if (isCancelled) return;
retEl.remove(); retTip.remove();
await delay(300);
}
async function startJourney() {
isPlaying = true;
isFinished = false;
isCancelled = false;
const myId = ++animId;
isPlaying = true; isFinished = false; isCancelled = false; visitedCodes = new Set();
// Build name → numeric ID map from loaded features
const nameToId = {};
for (const [id, feat] of Object.entries(featuresById)) {
if (feat.properties?.name) nameToId[feat.properties.name] = id;
const width = frameEl.clientWidth, height = frameEl.clientHeight;
svg.selectAll('*').remove();
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
setupProjection(width, height);
if (mode === 'globe' && homeFeature) {
const c = d3.geoCentroid(homeFeature);
projection.rotate([-c[0], -c[1]]);
pathFn = d3.geoPath().projection(projection);
}
renderMap();
// Use real journal entries, sorted by date
const nameToId = Object.fromEntries(Object.entries(featuresById).filter(([,f]) => f.properties?.name).map(([id, f]) => [f.properties.name, id]));
const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
const trips = entries.length > 0
? entries.map(e => ({
countryName: e.location.country,
countryCode: nameToId[e.location.country] ?? null,
city: e.location.cities[0] ?? e.location.country,
city: e.location.cities?.[0] ?? e.location.country,
transport: e.transport ?? 'flight',
date: e.date,
})).filter(t => t.countryCode)
: [
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight' },
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight' },
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight' },
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight' },
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight' },
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight' },
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' },
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' },
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' },
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' },
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' },
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' },
];
const total = trips.length;
for (let i = 0; i < total; i++) {
if (isCancelled) break;
for (let i = 0; i < trips.length; i++) {
if (isCancelled || myId !== animId) break;
const trip = trips[i];
if (trip.date) currentDateLabel = formatDateLabel(trip.date);
const destFeature = featuresById[trip.countryCode];
if (!destFeature) continue;
const label = `${trip.city}, ${trip.countryName}`;
if (onprogress) onprogress({ index: i + 1, total, label });
if (onprogress) onprogress({ index: i + 1, total: trips.length, label: `${trip.city}, ${trip.countryName}` });
await animateTrip(trip.countryCode, destFeature, trip.transport);
}
if (!isCancelled) {
isFinished = true;
isPlaying = false;
if (!isCancelled && myId === animId) {
isFinished = true; isPlaying = false;
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
} else {
isPlaying = false;
}
setTimeout(() => close(), 2500);
} else if (myId === animId) { isPlaying = false; }
}
function stopJourney() {
isCancelled = true;
isPlaying = false;
function stopJourney() { isCancelled = true; isPlaying = false; }
function replay() { stopJourney(); setTimeout(() => startJourney(), 100); }
function switchMode(target) {
if (target === mode) return;
onmodechange?.(target);
stopJourney();
setTimeout(() => startJourney(), 100);
}
function close() { stopJourney(); onclose?.(); }
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
const width = frameEl.clientWidth, height = frameEl.clientHeight;
setupProjection(width, height);
projection = d3.geoMercator();
fitProjection(projection, width, height);
pathFn = d3.geoPath().projection(projection);
const countries = feature(worldData, worldData.objects.countries)
countriesData = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countries.forEach(f => {
if (!f.id) f.id = 'XK';
});
for (const f of countries) {
featuresById[effId(f)] = f;
}
countriesData.forEach(f => { if (!f.id) f.id = 'XK'; });
for (const f of countriesData) featuresById[effId(f)] = f;
homeFeature = featuresById[HOME_CODE];
svg = d3.select(frameEl)
.append('svg')
.attr('width', width)
.attr('height', height)
.style('cursor', 'default');
g = svg.append('g');
countryPaths = g.selectAll('path')
.data(countries)
.join('path')
.attr('d', pathFn)
.attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5);
function renderMicrostates() {
g.selectAll('.micro-state-j').remove();
const threshold = 4;
countryPaths.each(function (d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = pathFn.centroid(d);
g.append('circle')
.attr('class', 'micro-state-j')
.datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 2)
.attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
}
});
}
renderMicrostates();
svg = d3.select(frameEl).append('svg').attr('width', width).attr('height', height).style('cursor', 'default');
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
renderMap();
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height);
countryPaths.attr('d', pathFn);
renderMicrostates();
const prevRotate = mode === 'globe' ? projection.rotate() : null;
if (mode === 'map') {
projection = d3.geoMercator();
projection.fitSize([width, height], { type: 'Sphere' });
projection.scale(projection.scale() * 1.5).translate([width / 2, height * 0.70]);
} else {
const size = Math.min(width, height) * 0.92;
projection = d3.geoOrthographic().rotate(prevRotate).fitSize([size, size], { type: 'Sphere' }).translate([width / 2, height / 2]);
}
pathFn = d3.geoPath().projection(projection);
redrawBase();
if (mode === 'map') renderMicrostates();
}
});
observer.observe(frameEl);
startJourney();
return () => {
stopJourney();
observer.disconnect();
if (svg) svg.remove();
};
return () => { stopJourney(); observer.disconnect(); if (svg) svg.remove(); };
});
</script>
<div bind:this={frameEl} class="journey-frame">
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
{#if isFinished}
<div class="done-badge">Journey complete!</div>
{/if}
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
<div class="top-label">
{#if isFinished}Journey complete!{:else if currentDateLabel}{currentDateLabel}{/if}
</div>
<div class="control-bar">
<button class="control-btn" onclick={replay}>
⟳ Replay
</button>
<button class="control-btn" onclick={() => switchMode(mode === 'map' ? 'globe' : 'map')}>
{mode === 'map' ? 'Globe animation' : 'Map animation'}
</button>
<button class="control-btn" onclick={close}>
✕ Back to Journaling
</button>
</div>
</div>
<style>
.journey-frame {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: #a4c8e0;
.journey-frame { width: 100%; height: 100%; overflow: hidden; position: relative; background: #a4c8e0; }
.journey-frame.globe-mode { background: #ffffff; }
.journey-frame :global(svg) { display: block; }
.top-label {
position: absolute; top: 16px; left: 16px; z-index: 10;
background: rgba(0,0,0,0.65); color: #fff;
font-family: var(--heading, sans-serif); font-size: 16px; font-weight: 600;
padding: 10px 24px; border-radius: 24px; white-space: nowrap;
letter-spacing: 0.04em; min-width: 200px; text-align: center;
}
.journey-frame :global(svg) {
display: block;
.control-bar {
position: absolute; bottom: 24px; right: 24px; z-index: 10;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
}
.close-btn {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: #8b5cf6;
color: #fff;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.4);
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
}
.close-btn:hover {
background: #7c3aed;
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
}
.close-btn:active {
transform: scale(0.92);
}
.done-badge {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0,0,0,0.65);
color: #fff;
font-family: var(--heading, sans-serif);
font-size: 16px;
padding: 10px 24px;
border-radius: 24px;
white-space: nowrap;
letter-spacing: 0.04em;
.control-btn {
padding: 10px 24px; border: none; border-radius: 24px;
background: #8b5cf6; color: #fff; font-size: 14px; font-weight: 600;
cursor: pointer; transition: background 0.15s ease; white-space: nowrap; font-family: inherit;
}
.control-btn:hover { background: #7c3aed; }
.control-btn:active { transform: scale(0.96); }
</style>

View File

@@ -1,18 +1,46 @@
<script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected } from '../layout/selection.svelte.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
import worldData from 'world-atlas/countries-50m.json';
let hoveredSeg = $state(null);
let collapsed = $state(false);
const continentColors = {
'Europe': '#6366f1',
'Asia': '#f43f5e',
'Africa': '#fb923c',
'N. America': '#06b6d4',
'S. America': '#f59e0b',
'Oceania': '#8b5cf6'
'Europe': '#3b82f6',
'Asia': '#ef4444',
'Africa': '#f97316',
'N. America': '#ec4899',
'S. America': '#eab308',
'Oceania': '#a16207'
};
const countryNameById = $derived.by(() => {
const map = { XK: 'Kosovo' };
for (const g of worldData.objects.countries.geometries) {
map[g.id] = g.properties?.name || g.id;
}
return map;
});
let visitedCountries = $derived(
[...getSelected()].map(id => countryNameById[id]).filter(Boolean).sort()
);
let visitedByContinent = $derived.by(() => {
const map = {};
for (const id of getSelected()) {
const cont = getContinent(id);
if (cont) {
if (!map[cont]) map[cont] = [];
map[cont].push(countryNameById[id] || id);
}
}
for (const cont of Object.keys(map)) {
map[cont].sort();
}
return map;
});
let counts = $derived.by(() => {
const c = {};
for (const cont of CONTINENTS) c[cont] = 0;
@@ -36,9 +64,11 @@
if (angle > 0) {
const startDeg = deg;
const endDeg = deg + angle;
const midDeg = (startDeg + endDeg) / 2;
const rad = (midDeg - 90) * Math.PI / 180;
const sr = (startDeg - 90) * Math.PI / 180;
const er = (endDeg - 90) * Math.PI / 180;
const cx = 50, cy = 50, outerR = 44, innerR = 22;
const cx = 90, cy = 90, outerR = 65, innerR = 30;
const x1 = cx + outerR * Math.cos(sr);
const y1 = cy + outerR * Math.sin(sr);
const x2 = cx + outerR * Math.cos(er);
@@ -49,7 +79,9 @@
const y4 = cy + innerR * Math.sin(sr);
const largeArc = angle > 180 ? 1 : 0;
const path = `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${largeArc} 0 ${x4} ${y4} Z`;
segs.push({ cont, color: continentColors[cont], path, angle });
const lx = cx + 82 * Math.cos(rad);
const ly = cy + 82 * Math.sin(rad);
segs.push({ cont, color: continentColors[cont], path, lx, ly, angle });
deg += angle;
}
}
@@ -57,199 +89,296 @@
});
</script>
<div class="card">
<!-- count -->
<div class="stat-block">
<span class="big-num">{total}</span>
<span class="stat-sub">countries visited</span>
</div>
<div class="panel" class:collapsed>
<button class="collapse-btn" onclick={() => collapsed = !collapsed} data-tip={collapsed ? 'see statistics' : 'close statistics'}>
{collapsed ? '◀' : '▶'}
</button>
<div class="vdivider"></div>
{#if !collapsed}
<div class="panel-content">
<h2 class="headline">your statistics</h2>
<!-- world % -->
<div class="stat-block">
<span class="big-num accent">{pct}%</span>
<span class="stat-sub">of the world</span>
</div>
<div class="total-bar-bg">
<div class="total-bar-fill" style="width: {pct}%"></div>
<span class="bar-pct">{pct}%</span>
</div>
<span class="total-bar-text">{total} / {grandTotal} countries visited</span>
<div class="vdivider"></div>
<div class="divider"></div>
<!-- donut -->
<div class="donut-block">
<svg viewBox="0 0 100 100" class="donut-svg">
{#if segments.length > 0}
{#each segments as seg}
<g class="seg-group"
onmouseenter={() => hoveredSeg = seg}
onmouseleave={() => hoveredSeg = null}>
<path d={seg.path} fill={seg.color} />
</g>
{/each}
<circle cx="50" cy="50" r="22" fill="#fff" />
{:else}
<circle cx="50" cy="50" r="44" fill="#f1f5f9" />
<circle cx="50" cy="50" r="22" fill="#fff" />
{/if}
</svg>
<div class="donut-info">
<span class="section-label">by continent</span>
{#if hoveredSeg}
<div class="tooltip" style="--dot:{hoveredSeg.color}">
<span class="tt-name">{hoveredSeg.cont}</span>
<span class="tt-val">{counts[hoveredSeg.cont]} / {continentTotals[hoveredSeg.cont]}</span>
<span class="bar-label">by continent</span>
{#each CONTINENTS as continent}
{@const contTotal = continentTotals[continent]}
<div class="row tooltip-wrap">
<span class="dot" style="background: {continentColors[continent]}"></span>
<span class="label">{continent}</span>
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
{#if visitedByContinent[continent]?.length > 0}
<div class="tooltip-list">
{#each visitedByContinent[continent].slice(0, 10) as country}
<span class="tooltip-item">{country}</span>
{/each}
{#if visitedByContinent[continent].length > 10}
<span class="tooltip-item tooltip-more">...</span>
{/if}
</div>
{/if}
</div>
{:else}
<span class="hint">hover a slice</span>
{/if}
</div>
</div>
{/each}
<div class="vdivider"></div>
<div class="donut-wrap">
{#if segments.length > 0}
<svg viewBox="-25 -25 230 230" class="donut-svg">
{#each segments as seg}
<g class="seg-group">
<path d={seg.path} fill={seg.color} />
<text x={seg.lx} y={seg.ly} text-anchor="middle" dominant-baseline="middle" class="donut-label" style="font-size: {seg.angle < 20 ? 12 : 15}px">{seg.cont}</text>
</g>
{/each}
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
</svg>
{:else}
<svg viewBox="-25 -25 230 230" class="donut-svg">
<circle cx="90" cy="90" r="65" fill="var(--border)" />
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
</svg>
{/if}
</div>
<!-- progress bar -->
<div class="bar-block">
<span class="section-label" style="margin-bottom:6px">world coverage</span>
<div class="bar-bg">
<div class="bar-fill" style="width:{pct}%"></div>
<div class="divider"></div>
<div class="disclaimer">Contains all UN countries, Kosovo, Hong Kong and Taiwan</div>
</div>
<span class="disclaimer">All UN countries · Kosovo · HK · Taiwan</span>
</div>
{/if}
</div>
<style>
.card {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
border: 1px solid rgba(0,0,0,0.06);
.panel {
flex: 0 0 min(360px, 25vw);
background: var(--bg-raised);
border-left: 1px solid var(--border);
display: flex;
flex-direction: row;
align-items: center;
gap: 0;
padding: 0 4px;
height: 110px;
z-index: 10;
font-family: var(--sans);
white-space: nowrap;
transition: flex-basis 0.25s ease;
}
.stat-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 36px;
.panel.collapsed {
flex: 0 0 28px;
border-left: none;
}
.big-num {
font-size: 40px;
font-weight: 300;
letter-spacing: -2px;
color: var(--text-h);
.panel-content {
flex: 1;
padding: 24px 28px;
overflow-y: auto;
min-width: 0;
}
.collapse-btn {
flex: 0 0 auto;
align-self: flex-start;
background: var(--accent-bg);
border: none;
border-radius: 0 8px 8px 0;
padding: 14px 5px;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: var(--accent);
transition: background 0.15s ease, padding 0.15s ease;
margin-top: 24px;
position: relative;
}
.big-num.accent { color: var(--accent); }
.stat-sub {
.collapse-btn:hover {
background: var(--lavender-bg);
padding-right: 8px;
}
.collapse-btn::after {
content: attr(data-tip);
position: absolute;
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
background: var(--text-h);
color: var(--bg-raised);
font-family: var(--sans);
font-size: 12px;
font-weight: 300;
color: var(--text-sub);
letter-spacing: 0.03em;
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.vdivider {
width: 1px;
height: 56px;
background: var(--border);
flex-shrink: 0;
.collapse-btn:hover::after {
opacity: 1;
}
/* donut */
.donut-block {
display: flex;
flex-direction: row;
align-items: center;
gap: 14px;
padding: 0 28px;
}
.donut-svg {
width: 72px;
height: 72px;
flex-shrink: 0;
}
.seg-group { cursor: pointer; }
.seg-group:hover path { opacity: 0.8; }
.donut-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 130px;
}
.tooltip {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
}
.tooltip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dot);
flex-shrink: 0;
}
.tt-name { font-weight: 400; color: var(--text-h); }
.tt-val { font-weight: 300; color: var(--text-sub); }
.section-label {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.14em;
.headline {
font-family: var(--heading);
font-size: var(--text-sm);
font-weight: 400;
text-transform: uppercase;
color: var(--text-sub);
letter-spacing: 0.1em;
color: var(--accent);
margin: 0 0 20px 0;
}
.hint {
font-size: 12px;
.bar-label {
font-family: var(--sans);
font-size: var(--text-xs);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-sub);
opacity: 0.45;
display: block;
margin-bottom: 8px;
}
/* bar */
.bar-block {
display: flex;
flex-direction: column;
padding: 0 28px;
gap: 0;
min-width: 160px;
}
.bar-bg {
width: 100%;
height: 5px;
background: var(--bg-subtle);
border-radius: 4px;
.total-bar-bg {
position: relative;
height: 28px;
background: var(--accent-bg);
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.bar-fill {
.total-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #a78bfa);
border-radius: 4px;
background: linear-gradient(90deg, var(--accent-dark), var(--lavender));
border-radius: 10px;
transition: width 0.3s ease;
min-width: 0;
}
.disclaimer {
font-size: 11px;
.bar-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-h);
}
.total-bar-text {
display: block;
text-align: center;
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
margin-top: 6px;
}
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
flex: 1;
font-size: var(--text-sm);
font-weight: 300;
color: var(--text);
}
.value {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
}
.total {
font-weight: 400;
color: var(--text-sub);
opacity: 0.5;
letter-spacing: 0.02em;
font-size: var(--text-xs);
}
.donut-wrap {
display: flex;
justify-content: center;
margin: 8px 0 20px;
}
.donut-svg {
width: 180px;
height: 180px;
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15));
}
.donut-label {
fill: var(--text-h);
font-family: var(--sans);
font-weight: 300;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.seg-group:hover .donut-label {
opacity: 1;
}
.tooltip-wrap {
position: relative;
}
.tooltip-list {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
background: var(--text-h);
color: var(--bg-raised);
font-family: var(--sans);
font-size: 12px;
line-height: 1.5;
padding: 8px 12px;
border-radius: 8px;
box-shadow: var(--shadow);
z-index: 20;
white-space: nowrap;
min-width: 120px;
}
.tooltip-wrap:hover .tooltip-list {
display: block;
}
.tooltip-item {
display: block;
padding: 2px 0;
}
.tooltip-item + .tooltip-item {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.disclaimer {
font-size: var(--text-xs);
color: var(--text-sub);
line-height: 1.5;
text-align: center;
}
</style>

View File

@@ -3,7 +3,11 @@
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
import { getSelected, setTotalCount } from '../layout/selection.svelte.js';
import { getSelected, setTotalCount, getFlashing } from '../layout/selection.svelte.js';
import { getUserProfile } from '../auth/userStore.svelte.js';
import { nameToId } from '../shared/countries.js';
import homeIconUrl from '../../assets/home.png';
import crayonCursorUrl from '../../assets/logo-cursor.png';
let { onCountryClick = (_name) => {} } = $props();
@@ -60,21 +64,24 @@
function countryColor(d, sel, homeCode) {
const id = effId(d);
if (!sel.has(id)) return UNVISITED_COLOR;
if (id === homeCode) return HOME_COLOR;
if (!sel.has(id)) return UNVISITED_COLOR;
return VISITED_COLOR;
}
function countryHoverColor(d, sel, homeCode) {
const id = effId(d);
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
if (id === homeCode) return HOME_COLOR_HOVER;
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
return VISITED_COLOR_HOVER;
}
let frameEl;
let _paths = null;
let _paths = $state(null);
let _g = null;
let _pathFn = null;
let _countries = null;
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
@@ -82,15 +89,60 @@
proj.scale(s).translate([w / 2, h * 0.70]);
}
function getHomeCode() {
const name = getUserProfile()?.homeCountry;
return name ? (nameToId[name] ?? null) : null;
}
function updateAllFills() {
const sel = getSelected();
const hc = getHomeCode();
if (!_paths || !_g) return;
_paths.attr('fill', d => countryColor(d, sel, null));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, null));
_paths.attr('fill', d => countryColor(d, sel, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
}
$effect(updateAllFills);
function placeHomeMarker() {
if (!_g || !_pathFn || !_countries) return;
_g.selectAll('.home-marker').remove();
const name = getUserProfile()?.homeCountry;
if (!name) return;
const found = _countries.find(f => f.properties.name === name);
if (!found) return;
const [cx, cy] = _pathFn.centroid(found);
if (isNaN(cx) || isNaN(cy)) return;
const SIZE = 14;
_g.append('image')
.attr('class', 'home-marker')
.attr('href', homeIconUrl)
.attr('x', cx - SIZE / 2)
.attr('y', cy - SIZE / 2)
.attr('width', SIZE)
.attr('height', SIZE)
.style('pointer-events', 'none');
}
$effect(placeHomeMarker);
$effect(() => {
const flashSet = getFlashing();
const paths = _paths; // reactive read so effect re-runs when _paths is set
if (!paths || flashSet.size === 0) return;
paths
.filter(d => flashSet.has(effId(d)))
.each(function() {
d3.select(this).interrupt()
.transition().duration(200).attr('fill', '#facc15')
.transition().duration(200).attr('fill', '#fb923c')
.transition().duration(200).attr('fill', '#facc15')
.transition().duration(200).attr('fill', '#fb923c')
.transition().duration(200).attr('fill', '#facc15')
.transition().duration(400).attr('fill', VISITED_COLOR);
});
});
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
@@ -107,6 +159,9 @@
if (!f.id) f.id = 'XK';
});
_pathFn = path;
_countries = countries;
const sovereignIds = new Set(countries.map(f => effId(f)));
setTotalCount(sovereignIds.size);
@@ -129,16 +184,16 @@
})
.on('mouseenter', (event, d) => {
const s = getSelected();
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, null));
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, getHomeCode()));
tooltip.style('display', 'block').text(d.properties.name);
})
.on('mousemove', (event) => {
const [x, y] = d3.pointer(event, frameEl);
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
tooltip.style('left', (x + 22) + 'px').style('top', (y - 28) + 'px');
})
.on('mouseleave', (event, d) => {
const s = getSelected();
d3.select(event.currentTarget).attr('fill', countryColor(d, s, null));
d3.select(event.currentTarget).attr('fill', countryColor(d, s, getHomeCode()));
tooltip.style('display', 'none');
});
}
@@ -166,7 +221,7 @@
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 2)
.attr('fill', countryColor(d, getSelected(), null))
.attr('fill', countryColor(d, getSelected(), getHomeCode()))
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c);
@@ -175,6 +230,7 @@
}
renderMicrostates();
placeHomeMarker();
const zoom = d3.zoom()
.scaleExtent([1, 32])
@@ -198,8 +254,9 @@
fitProjection(projection, width, height);
const countryPaths = _g.selectAll('path');
countryPaths.attr('d', path);
updateFill(countryPaths);
updateAllFills();
renderMicrostates();
placeHomeMarker();
}
});
@@ -212,7 +269,7 @@
});
</script>
<div bind:this={frameEl} class="map-frame"></div>
<div bind:this={frameEl} class="map-frame" style="cursor: url({crayonCursorUrl}) 4 28, crosshair;"></div>
<style>
.map-frame {
@@ -225,15 +282,15 @@
.map-frame :global(svg) {
display: block;
cursor: grab;
cursor: inherit;
}
.map-frame :global(svg:active) {
cursor: grabbing;
cursor: inherit;
}
.map-frame :global(svg path) {
cursor: pointer;
cursor: inherit;
}
.map-frame :global(.tooltip) {

9
storage.rules Normal file
View File

@@ -0,0 +1,9 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read: if true;
allow write: if request.auth != null;
}
}
}