27 Commits

Author SHA1 Message Date
haerikimmm
d2fb40f692 added logo image and slogan to sign in page 2026-06-16 15:20:07 +09:00
haerikimmm
06e5fe5593 feat: add transport image icons and expand country list to match world map 2026-06-15 19:54:45 +09:00
haerikimmm
dd7932ea4e feat: Firebase integration, 3-step new trip form, full country list, and UI polish
- Connect Firestore for journal entries and visited countries (real-time onSnapshot)
- Connect Firebase Storage for photo uploads
- Add NewEntryForm: 3-step flow (trip details → photos → reflection questions)
- Expand country list to full world-atlas dataset (~240 countries) matching the map
- Filter city suggestions by selected country in both NewEntryForm and EditForm
- Redesign StatsPanel as floating horizontal card with donut chart and progress bar
- Center timeline layout with responsive side margins
- Replace "entry" language with "trip" throughout (Add trip, Save trip, Delete trip)
- Remove footer from Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:54:45 +09:00
haerikimmm
40e75f30e8 changed to centered layout 2026-06-15 19:50:13 +09:00
965f677368 fix after merge 2026-06-15 12:35:57 +09:00
611cc0b626 Merge branch 'feature/timeline' into main 2026-06-15 12:29:36 +09:00
70352be01b animation plane shape 2026-06-15 12:14:57 +09:00
haerikimmm
6cee6095ed added sharable stats 2026-06-15 10:20:55 +09:00
bd001a71fa add jurney animation 2026-06-15 00:06:10 +09:00
Haeri Kim
8422c6e34f changed app name, added new journal entry from map and timeline 2026-06-14 10:59:59 +09:00
Haeri Kim
cdf3643622 added edit page and revised edit form 2026-06-14 10:47:21 +09:00
Haeri Kim
aadc80b7a8 added firebase & changed main tab color, uyear stand out 2026-06-14 10:24:45 +09:00
c9c94d670f Merge feature/timeline into main 2026-06-12 20:06:25 +09:00
f198c05063 display home country in map 2026-06-12 19:19:20 +09:00
6701398da7 add saving countries 2026-06-12 18:20:00 +09:00
08d3e3ae56 add firebase and sign up 2026-06-12 18:19:45 +09:00
haerikimmm
9be793e2dd added jounal page 2026-06-12 15:55:43 +09:00
haerikimmm
cb0e5602c8 added flag, changed the location of the country name 2026-06-12 15:34:43 +09:00
haerikimmm
326b52b858 changed the location of the travel summary to left 2026-06-11 13:30:18 +09:00
haerikimmm
0848418453 changed font style, added journey summary, changed card ui 2026-06-11 13:21:09 +09:00
haerikimmm
6b13522d6d clean up folders, created sub-components 2026-06-11 11:28:58 +09:00
cd682f738a make stats panel colabseble 2026-06-10 22:56:56 +09:00
5356c05654 finished merging 2026-06-09 16:54:31 +09:00
640c241e1c Merge branch 'feature/timeline' 2026-06-09 16:50:37 +09:00
e62b68ede6 small visual changes 2026-06-09 16:18:37 +09:00
haerikimmm
b40e5886b5 moved country, days to top of the card & changed position
of tags
2026-06-02 16:45:21 +09:00
haerikimmm
04b5734b48 timeline card ui & sorting & view change 2026-06-02 13:58:53 +09:00
60 changed files with 8346 additions and 820 deletions

13
.claude/launch.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Map-Jurnal",
"cwd": "/Users/haerikim/Desktop/SP Map Journal/Map-Jurnal",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}

6
.env Normal file
View File

@@ -0,0 +1,6 @@
VITE_FIREBASE_API_KEY=AIzaSyC_hZf9TpIIb4H7y7umUeYtFKD-guN_iR0
VITE_FIREBASE_AUTH_DOMAIN=map-jurnal.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=map-jurnal
VITE_FIREBASE_STORAGE_BUCKET=map-jurnal.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=922587077950
VITE_FIREBASE_APP_ID=1:922587077950:web:9f140f84468e306152606f

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
.env
*.local
# Editor directories and files

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Map Journal</title>
<title>Journi</title>
</head>
<body>
<div id="app"></div>

2423
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,15 @@
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.55.5",
"vite": "^8.0.12"
"vite": "^6.3.5"
},
"dependencies": {
"d3": "^7.9.0",
"firebase": "^12.14.0",
"flag-icons": "^7.5.0",
"html-to-image": "^1.11.13",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
}

BIN
public/plane 2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
public/plane.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,39 +1,144 @@
<script>
import { initAuth, getLoading, getUser, getNeedsCountry } from './lib/auth/userStore.svelte.js';
import LoginOverlay from './lib/auth/LoginOverlay.svelte';
import CountryPicker from './lib/auth/CountryPicker.svelte';
import Layout from './lib/layout/Layout.svelte';
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 Timeline from './lib/Timeline.svelte';
import TimelineView from './lib/timeline/TimelineView.svelte';
let screen = $state('worldmap');
let journeyActive = $state(false);
let journeyProgress = $state(null);
let inDetail = $state(false);
let pendingCountry = $state('');
function onNavigate(s) {
screen = s;
}
function startJourney() {
journeyActive = true;
journeyProgress = null;
}
function endJourney() {
journeyActive = false;
journeyProgress = null;
}
function onJourneyProgress(p) {
journeyProgress = p;
}
function handleCountryClick(name) {
pendingCountry = name;
screen = 'timeline';
}
$effect(() => {
initAuth();
});
let loading = $derived(getLoading());
let user = $derived(getUser());
let needsCountry = $derived(getNeedsCountry());
</script>
<Layout {screen} {onNavigate}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area">
<WorldMap />
{#if loading}
<div class="loading-screen">
<span class="loading-text">Loading...</span>
</div>
{:else}
<Layout {screen} {onNavigate} hideTopBar={inDetail}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area">
{#if journeyActive}
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} />
{:else}
<WorldMap onCountryClick={handleCountryClick} />
<button class="journey-play-btn" onclick={startJourney}>▶</button>
{/if}
</div>
{#if !journeyActive}<StatsPanel />{/if}
</div>
<StatsPanel />
</div>
{:else}
<Timeline />
{:else}
<TimelineView
onDetailChange={(v) => (inDetail = v)}
{pendingCountry}
onNewEntryClear={() => (pendingCountry = '')}
onGoToMap={() => { screen = 'worldmap'; }}
/>
{/if}
</Layout>
{#if !user}
<LoginOverlay />
{:else if needsCountry}
<CountryPicker />
{/if}
</Layout>
{/if}
<style>
.loading-screen {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f172a;
}
.loading-text {
font: 400 18px/1.4 sans-serif;
color: #94a3b8;
}
.worldmap-page {
flex: 1;
display: flex;
flex-direction: row;
width: 100%;
min-width: 0;
height: 100%;
position: relative;
}
.map-area {
flex: 1;
overflow: hidden;
position: relative;
}
.journey-play-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: 20px;
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;
}
.journey-play-btn:hover {
background: #7c3aed;
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
}
.journey-play-btn:active {
transform: scale(0.92);
}
</style>

View File

@@ -1,11 +1,95 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500&display=swap');
/* ── Color tokens ─────────────────────────────────────────── */
:root {
--accent: #7c3aed; /* indigo-600 */
--accent-dark: #5b21b6;
--accent-light: #a78bfa;
--accent-bg: rgba(124, 58, 237, 0.07);
--accent-border: rgba(124, 58, 237, 0.2);
--lavender: #a78bfa;
--lavender-bg: rgba(167, 139, 250, 0.1);
/* Light-first neutrals */
--text: #52525b; /* zinc-600 */
--text-h: #18181b; /* zinc-900 */
--text-sub: #a1a1aa; /* zinc-400 */
--bg: #ffffff; /* white */
--bg-raised: #fafafa; /* off-white */
--bg-subtle: #f4f4f5; /* zinc-100 */
--border: #e4e4e7; /* zinc-200 */
--border-bright: #d4d4d8; /* zinc-300 */
--shadow: 0 4px 24px rgba(0,0,0,0.08);
/* Typography */
--sans: 'Bricolage Grotesque', system-ui, sans-serif;
--heading: 'Bricolage Grotesque', system-ui, sans-serif;
--mono: ui-monospace, Consolas, monospace;
/* Type scale */
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 16px;
--text-lg: 20px;
--text-xl: 28px;
--text-2xl: 40px;
font-family: var(--sans);
font-size: var(--text-base);
line-height: 1.6;
font-weight: 300;
letter-spacing: 0.01em;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body {
/* ── Reset ────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #app {
width: 100%;
height: 100%;
overflow: hidden;
}
/* ── Text hierarchy ───────────────────────────────────────── */
h1 {
font-size: var(--text-2xl);
font-weight: 400;
line-height: 1.1;
letter-spacing: -1px;
color: var(--text-h);
}
h2 {
font-size: var(--text-xl);
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.5px;
color: var(--text-h);
}
h3 {
font-size: var(--text-lg);
font-weight: 300;
line-height: 1.3;
color: var(--text-h);
}
h4, h5, h6 {
font-size: var(--text-md);
font-weight: 300;
color: var(--text-h);
}
p { margin: 0; color: var(--text); }

BIN
src/assets 2/airplane 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/assets 2/bus 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
src/assets 2/car 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
src/assets 2/ship 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
src/assets 2/train 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
src/assets 2/walk 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/airplane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/assets/bus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
src/assets/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
src/assets/ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
src/assets/train.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
src/assets/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,15 +0,0 @@
<div class="placeholder">
<p>Timeline — coming soon</p>
</div>
<style>
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font: 16px/1.4 sans-serif;
color: #888;
}
</style>

View File

@@ -0,0 +1,208 @@
<script>
import { getUser, getUserProfile, setHomeCountry } from './userStore.svelte.js';
import worldData from 'world-atlas/countries-50m.json';
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 open = $state(false);
function select(c) {
selectedCountry = c;
search = c.name;
open = false;
}
function handleSubmit() {
if (selectedCountry) {
setHomeCountry(selectedCountry.name, selectedCountry.code);
}
}
function handleKeydown(e) {
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>
<div class="dropdown" class:open>
<input
type="text"
placeholder="Search for a country..."
bind:value={search}
onfocus={() => { open = true; }}
oninput={() => { open = true; selectedCountry = null; }}
onkeydown={handleKeydown}
class="search-input"
/>
{#if open}
<ul class="list" role="listbox">
{#each filtered as country}
<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); }}
tabindex="0"
>
{country.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>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(4px);
}
.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;
width: 90%;
}
.heading {
font: 700 24px/1.3 sans-serif;
color: #f1f5f9;
margin-bottom: 6px;
}
.subtitle {
font: 400 15px/1.4 sans-serif;
color: #94a3b8;
margin-bottom: 28px;
}
.dropdown {
position: relative;
margin-bottom: 24px;
text-align: left;
}
.search-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #475569;
border-radius: 8px;
background: #0f172a;
color: #f1f5f9;
font: 400 15px/1.4 sans-serif;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #3b82f6;
}
.search-input::placeholder {
color: #64748b;
}
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: #0f172a;
border: 1px solid #475569;
border-radius: 8px;
list-style: none;
z-index: 10;
}
.list li {
padding: 10px 16px;
cursor: pointer;
color: #cbd5e1;
font: 400 14px/1.4 sans-serif;
transition: background 0.15s;
}
.list li:hover,
.list li.selected {
background: #1e3a5f;
color: #f1f5f9;
}
.no-results {
color: #64748b;
cursor: default;
}
.continue-btn {
width: 100%;
padding: 12px 24px;
border: none;
border-radius: 8px;
background: #3b82f6;
color: #fff;
font: 600 16px/1.4 sans-serif;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.continue-btn:hover:not(:disabled) {
background: #2563eb;
}
.continue-btn:disabled {
opacity: 0.4;
cursor: default;
}
</style>

View File

@@ -0,0 +1,95 @@
<script>
import { signInWithGoogle } from './userStore.svelte.js';
import logoImg from '../../assets/logo.png';
</script>
<div class="overlay">
<div class="card">
<img src={logoImg} alt="Journi" class="logo" />
<h1 class="title">Journi</h1>
<p class="subtitle">Collect Colors Along the Way</p>
<button class="google-btn" onclick={signInWithGoogle}>
<svg class="google-icon" viewBox="0 0 48 48">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 0 1 9.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 0 0 0 24c0 3.77.87 7.35 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
<path fill="none" d="M0 0h48v48H0z"/>
</svg>
Sign in with Google
</button>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.card {
text-align: center;
max-width: 360px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.logo {
width: 216px;
height: 216px;
object-fit: contain;
margin-bottom: 16px;
}
.title {
font-family: var(--heading);
font-size: 28px;
font-weight: 600;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0;
}
.subtitle {
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text);
margin: 0 0 32px;
}
.google-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-subtle);
color: var(--text-h);
font-family: var(--sans);
font-size: 14px;
font-weight: 400;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.google-btn:hover {
background: var(--bg);
border-color: var(--accent-border);
}
.google-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,71 @@
import { auth, db, googleProvider } from '../firebase.js';
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
let _initialized = false;
let user = $state(null);
let userProfile = $state(null);
let loading = $state(true);
let needsCountry = $state(false);
export function getUser() { return user; }
export function getUserProfile() { return userProfile; }
export function getLoading() { return loading; }
export function getNeedsCountry() { return needsCountry; }
export async function signInWithGoogle() {
await signInWithPopup(auth, googleProvider);
}
export async function signOut() {
await fbSignOut(auth);
user = null;
userProfile = null;
needsCountry = false;
}
export async function setHomeCountry(name, code) {
if (!user) return;
await setDoc(doc(db, 'users', user.uid), {
displayName: user.displayName,
photoURL: user.photoURL,
email: user.email,
homeCountry: name,
homeCountryCode: code,
visitedCountries: [code],
createdAt: serverTimestamp(),
});
userProfile = { ...userProfile, homeCountry: name, homeCountryCode: code, visitedCountries: [code] };
needsCountry = false;
}
export function initAuth() {
if (_initialized) return;
_initialized = true;
onAuthStateChanged(auth, async (fbUser) => {
if (fbUser) {
user = fbUser;
initEntriesListener(fbUser.uid);
const docRef = doc(db, 'users', fbUser.uid);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
userProfile = docSnap.data();
needsCountry = false;
} else {
userProfile = {
displayName: fbUser.displayName,
photoURL: fbUser.photoURL,
email: fbUser.email,
};
needsCountry = true;
}
} else {
user = null;
userProfile = null;
needsCountry = false;
}
loading = false;
});
}

19
src/lib/firebase 2.js Normal file
View File

@@ -0,0 +1,19 @@
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();

19
src/lib/firebase.js Normal file
View File

@@ -0,0 +1,19 @@
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,19 +1,25 @@
<script>
import { getSelected, getTotalCount } from './selection.svelte.js';
</script>
<footer class="footer">
© 2026 Tomas Horsky &amp; Haeri Kim
<span>{getSelected().size} / {getTotalCount()} countries visited</span>
</footer>
<style>
.footer {
height: 32px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 24px;
background: #334155;
font: 15px/1.6 sans-serif;
color: #cbd5e1;
position: relative;
z-index: 10;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
justify-content: center;
font-family: var(--sans);
font-size: 12px;
font-weight: 300;
color: var(--text-sub);
border-top: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
letter-spacing: 0.06em;
text-transform: uppercase;
}
</style>

View File

@@ -1,16 +1,16 @@
<script>
import TopBar from './TopBar.svelte';
import Footer from './Footer.svelte';
let { screen, onNavigate, children } = $props();
let { screen, onNavigate, hideTopBar = false, children } = $props();
</script>
<div class="layout">
<TopBar {screen} {onNavigate} />
<div class="layout" class:no-topbar={hideTopBar}>
{#if !hideTopBar}
<TopBar {screen} {onNavigate} />
{/if}
<main class="main">
{@render children()}
</main>
<Footer />
</div>
<style>
@@ -18,12 +18,19 @@
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr;
overflow: hidden;
}
.layout.no-topbar {
grid-template-rows: 1fr;
}
.main {
overflow: hidden;
position: relative;
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,11 +1,30 @@
<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());
let profile = $derived(getUserProfile());
let menuOpen = $state(false);
function toggleMenu() {
menuOpen = !menuOpen;
}
function handleSignOut() {
menuOpen = false;
signOut();
}
</script>
<div class="topbar">
<div class="left">
<img src="/logo.png" alt="Logo" class="logo" />
<span class="app-name">Map Journal</span>
<div class="brand">
<img src={logo1Img} class="logo-img" alt="Journi logo" />
<span class="app-name">Journi</span>
</div>
</div>
<div class="center">
@@ -20,21 +39,46 @@
</div>
<div class="right">
<img src="/profile.jpg" alt="Profile" class="avatar" />
{#if user}
<div class="avatar-wrapper">
<button class="avatar-btn" onclick={toggleMenu} onkeydown={(e) => { if (e.key === 'Enter') toggleMenu(); }}>
<img
src={user.photoURL || '/profile.jpg'}
alt="Profile"
class="avatar"
/>
</button>
{#if menuOpen}
<div class="dropdown-menu">
<div class="menu-header">
<span class="menu-name">{profile?.displayName || user.displayName}</span>
<span class="menu-email">{user.email}</span>
</div>
<div class="divider"></div>
<button class="menu-item" onclick={handleSignOut}>Sign out</button>
</div>
{/if}
</div>
{/if}
</div>
{#if menuOpen}
<button class="backdrop" aria-label="Close menu" onclick={() => { menuOpen = false; }}></button>
{/if}
</div>
<style>
.topbar {
height: 64px;
height: 52px;
display: flex;
align-items: center;
padding: 0 24px;
background: #1e2937;
padding: 0 32px;
gap: 16px;
position: relative;
z-index: 10;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.left {
@@ -43,18 +87,25 @@
gap: 10px;
}
.logo {
width: 75px;
height: 75px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
.brand {
display: flex;
align-items: center;
gap: 4px;
}
.logo-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.app-name {
font: 700 20px/1.2 sans-serif;
color: #f1f5f9;
font-family: var(--heading);
font-size: 22px;
font-weight: 600;
color: var(--text-h);
white-space: nowrap;
letter-spacing: -0.5px;
}
.center {
@@ -66,20 +117,21 @@
.segmented {
position: relative;
display: flex;
background: rgba(255, 255, 255, 0.1);
border-radius: 999px;
padding: 4px;
width: 300px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
}
.slider {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: #fff;
border-radius: 999px;
top: 3px;
left: 3px;
width: calc(50% - 3px);
height: calc(100% - 6px);
background: var(--bg);
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
transition: transform 0.25s ease;
pointer-events: none;
}
@@ -88,25 +140,100 @@
position: relative;
z-index: 1;
flex: 1;
padding: 10px 20px;
padding: 4px 18px;
border: none;
background: none;
cursor: pointer;
font: 500 16px/1.4 sans-serif;
color: #cbd5e1;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text);
letter-spacing: 0.01em;
}
.right {
display: flex;
align-items: center;
}
.avatar-wrapper {
position: relative;
}
.avatar-btn {
display: flex;
padding: 0;
border: none;
background: none;
cursor: pointer;
border-radius: 50%;
}
.avatar {
width: 45px;
height: 45px;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
flex-shrink: 0;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 0;
min-width: 200px;
box-shadow: var(--shadow);
z-index: 50;
}
.menu-header {
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
.menu-name {
font: 600 14px/1.3 sans-serif;
color: var(--text-h);
}
.menu-email {
font: 400 12px/1.3 sans-serif;
color: var(--text-sub);
}
.divider {
height: 1px;
background: var(--border);
margin: 6px 0;
}
.menu-item {
width: 100%;
padding: 8px 16px;
border: none;
background: none;
text-align: left;
font: 400 14px/1.4 sans-serif;
color: #ef4444;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: var(--bg-subtle);
}
.backdrop {
position: fixed;
inset: 0;
z-index: 30;
border: none;
background: transparent;
cursor: default;
}
</style>

View File

@@ -1,19 +1,18 @@
import { journals } from '../stores/journalStore.js';
import { nameToId } from '../shared/countries.js';
let selected = $state(new Set());
let totalCountries = $state(0);
export function toggle(id) {
const next = new Set(selected);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
// 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);
}
selected = next;
}
export function clearAll() {
selected = new Set();
}
selected = ids;
});
export function getSelected() {
return selected;

View File

@@ -0,0 +1,167 @@
<script>
/**
* Reusable photo gallery with prev/next arrows and indicator.
* @type {{
* photos: string[],
* height?: string,
* thumbs?: boolean,
* counter?: boolean,
* onStep?: (e: Event) => void,
* }}
*/
let { photos, height = '220px', thumbs = false, counter = false } = $props();
let idx = $state(0);
function prev(e) {
e?.stopPropagation();
idx = (idx - 1 + photos.length) % photos.length;
}
function next(e) {
e?.stopPropagation();
idx = (idx + 1) % photos.length;
}
function go(i, e) {
e?.stopPropagation();
idx = i;
}
// Reset when photos change (e.g. navigating to a different entry)
$effect(() => {
photos;
idx = 0;
});
</script>
{#if photos.length > 0}
<div class="gallery" style="--gallery-height: {height}">
<img class="gallery-img" src={photos[idx]} alt="photo {idx + 1}" loading="lazy" />
{#if photos.length > 1}
<button class="arr left" onclick={prev} aria-label="Previous photo"></button>
<button class="arr right" onclick={next} aria-label="Next photo"></button>
{#if thumbs}
<div class="thumb-strip">
{#each photos as photo, i}
<button class="thumb" class:active={i === idx} onclick={(e) => go(i, e)} aria-label="Photo {i + 1}">
<img src={photo} alt="" />
</button>
{/each}
</div>
{:else}
<div class="dots">
{#each photos as _, i}
<button class="pip" class:active={i === idx} onclick={(e) => go(i, e)} aria-label="Photo {i + 1}"></button>
{/each}
</div>
{/if}
{/if}
{#if counter}
<span class="counter">{idx + 1} / {photos.length}</span>
{/if}
</div>
{/if}
<style>
.gallery {
position: relative;
overflow: hidden;
background: #000;
}
.gallery-img {
width: 100%;
height: var(--gallery-height);
object-fit: cover;
display: block;
}
.arr {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.45);
color: #fff;
border: none;
border-radius: 50%;
font-size: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
z-index: 2;
width: 36px;
height: 36px;
}
.arr:hover { background: rgba(0,0,0,0.7); }
.arr.left { left: 10px; }
.arr.right { right: 10px; }
/* Dot indicators (timeline cards) */
.dots {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
z-index: 2;
}
.pip {
width: 6px;
height: 6px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.5);
cursor: pointer;
padding: 0;
transition: background 0.15s, transform 0.15s;
}
.pip.active { background: #fff; transform: scale(1.3); }
/* Thumbnail strip (detail page) */
.thumb-strip {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 2;
}
.thumb {
width: 52px;
height: 36px;
border-radius: 6px;
overflow: hidden;
border: 2px solid transparent;
padding: 0;
cursor: pointer;
opacity: 0.65;
background: none;
transition: border-color 0.15s, opacity 0.15s;
}
.thumb.active { border-color: #fff; opacity: 1; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Photo counter badge */
.counter {
position: absolute;
top: 14px;
right: 14px;
font-size: 12px;
font-weight: 300;
color: #fff;
background: rgba(0,0,0,0.45);
padding: 3px 10px;
border-radius: 20px;
z-index: 2;
}
@media (max-width: 600px) {
.thumb { width: 40px; height: 28px; }
}
</style>

View File

@@ -0,0 +1,132 @@
<script>
/**
* Searchable combobox input.
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
*/
let { id, value = $bindable(), options, placeholder = '', required = false, onselect, onblurcommit } = $props();
let query = $state(value);
let open = $state(false);
let focused = $state(-1);
let filtered = $derived(
query.trim() === ''
? options
: options.filter(o => o.toLowerCase().includes(query.toLowerCase()))
);
function select(opt) {
query = opt;
value = opt;
open = false;
focused = -1;
onselect?.(opt);
}
function onInput(e) {
query = e.currentTarget.value;
value = query;
open = true;
focused = -1;
}
function onKeydown(e) {
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); }
else if (e.key === 'Enter') { e.preventDefault(); if (focused >= 0) { select(filtered[focused]); } else if (query.trim()) { select(query.trim()); } }
else if (e.key === 'Escape') { open = false; focused = -1; }
}
function onBlur() {
setTimeout(() => {
open = false;
focused = -1;
if (onblurcommit && query.trim()) onblurcommit(query.trim());
}, 150);
}
// Keep query in sync if value is changed externally
$effect(() => { query = value; });
</script>
<div class="combo">
<input
{id}
{required}
{placeholder}
class="combo-input"
type="text"
autocomplete="off"
value={query}
oninput={onInput}
onkeydown={onKeydown}
onfocus={() => open = true}
onblur={onBlur}
/>
{#if open && filtered.length > 0}
<ul class="dropdown" role="listbox">
{#each filtered as opt, i}
<li
class="option"
class:highlighted={i === focused}
role="option"
aria-selected={opt === value}
onmousedown={() => select(opt)}
>{opt}</li>
{/each}
</ul>
{/if}
</div>
<style>
.combo {
position: relative;
width: 100%;
}
.combo-input {
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text-h);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.combo-input:focus { border-color: var(--accent-border); }
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
list-style: none;
max-height: 200px;
overflow-y: auto;
z-index: 50;
padding: 4px;
}
.option {
font-size: 13px;
font-weight: 300;
color: var(--text);
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.option:hover, .option.highlighted {
background: var(--accent-bg);
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,84 @@
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
// Full name → alpha-2 map covering all world-atlas country names.
const nameToAlpha2 = {
'Afghanistan':'AF','Albania':'AL','Algeria':'DZ','American Samoa':'AS',
'Andorra':'AD','Angola':'AO','Anguilla':'AI','Antigua and Barb.':'AG',
'Argentina':'AR','Armenia':'AM','Aruba':'AW','Ashmore and Cartier Is.':'AU',
'Australia':'AU','Austria':'AT','Azerbaijan':'AZ','Bahamas':'BS',
'Bahrain':'BH','Bangladesh':'BD','Barbados':'BB','Belarus':'BY',
'Belgium':'BE','Belize':'BZ','Benin':'BJ','Bermuda':'BM','Bhutan':'BT',
'Bolivia':'BO','Bosnia and Herz.':'BA','Botswana':'BW',
'Br. Indian Ocean Ter.':'IO','Brazil':'BR','British Virgin Is.':'VG',
'Brunei':'BN','Bulgaria':'BG','Burkina Faso':'BF','Burundi':'BI',
'Cabo Verde':'CV','Cambodia':'KH','Cameroon':'CM','Canada':'CA',
'Cayman Is.':'KY','Central African Rep.':'CF','Chad':'TD','Chile':'CL',
'China':'CN','Colombia':'CO','Comoros':'KM','Congo':'CG','Cook Is.':'CK',
'Costa Rica':'CR','Croatia':'HR','Cuba':'CU','Curaçao':'CW','Cyprus':'CY',
'Czechia':'CZ',"Côte d'Ivoire":'CI','Dem. Rep. Congo':'CD','Denmark':'DK',
'Djibouti':'DJ','Dominica':'DM','Dominican Rep.':'DO','Ecuador':'EC',
'Egypt':'EG','El Salvador':'SV','Eq. Guinea':'GQ','Eritrea':'ER',
'Estonia':'EE','Ethiopia':'ET','Faeroe Is.':'FO','Falkland Is.':'FK',
'Fiji':'FJ','Finland':'FI','Fr. Polynesia':'PF','France':'FR','Gabon':'GA',
'Gambia':'GM','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR',
'Greenland':'GL','Grenada':'GD','Guam':'GU','Guatemala':'GT',
'Guernsey':'GG','Guinea':'GN','Guinea-Bissau':'GW','Guyana':'GY',
'Haiti':'HT','Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS',
'India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ','Ireland':'IE',
'Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP',
'Jersey':'JE','Jordan':'JO','Kazakhstan':'KZ','Kenya':'KE','Kiribati':'KI',
'Kosovo':'XK','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV',
'Lebanon':'LB','Lesotho':'LS','Liberia':'LR','Libya':'LY',
'Liechtenstein':'LI','Lithuania':'LT','Luxembourg':'LU','Macao':'MO',
'Macedonia':'MK','Madagascar':'MG','Malawi':'MW','Malaysia':'MY',
'Maldives':'MV','Mali':'ML','Malta':'MT','Marshall Is.':'MH',
'Mauritania':'MR','Mauritius':'MU','Mexico':'MX','Micronesia':'FM',
'Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME',
'Montserrat':'MS','Morocco':'MA','Mozambique':'MZ','Myanmar':'MM',
'N. Cyprus':'CY','N. Mariana Is.':'MP','Namibia':'NA','Nauru':'NR',
'Nepal':'NP','Netherlands':'NL','New Caledonia':'NC','New Zealand':'NZ',
'Nicaragua':'NI','Niger':'NE','Nigeria':'NG','Niue':'NU',
'Norfolk Island':'NF','North Korea':'KP','Norway':'NO','Oman':'OM',
'Pakistan':'PK','Palau':'PW','Palestine':'PS','Panama':'PA',
'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH',
'Pitcairn Is.':'PN','Poland':'PL','Portugal':'PT','Puerto Rico':'PR',
'Qatar':'QA','Romania':'RO','Russia':'RU','Rwanda':'RW','S. Sudan':'SS',
'Saint Helena':'SH','Saint Lucia':'LC','Samoa':'WS','San Marino':'SM',
'Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Seychelles':'SC',
'Sierra Leone':'SL','Singapore':'SG','Sint Maarten':'SX','Slovakia':'SK',
'Slovenia':'SI','Solomon Is.':'SB','Somalia':'SO','South Africa':'ZA',
'South Korea':'KR','Spain':'ES','Sri Lanka':'LK','St-Barthélemy':'BL',
'St-Martin':'MF','St. Kitts and Nevis':'KN','St. Pierre and Miquelon':'PM',
'St. Vin. and Gren.':'VC','Sudan':'SD','Suriname':'SR','Sweden':'SE',
'Switzerland':'CH','Syria':'SY','São Tomé and Principe':'ST','Taiwan':'TW',
'Tajikistan':'TJ','Tanzania':'TZ','Thailand':'TH','Timor-Leste':'TL',
'Togo':'TG','Tonga':'TO','Trinidad and Tobago':'TT','Tunisia':'TN',
'Turkey':'TR','Turkmenistan':'TM','Turks and Caicos Is.':'TC',
'U.S. Virgin Is.':'VI','Uganda':'UG','Ukraine':'UA',
'United Arab Emirates':'AE','United Kingdom':'GB',
'United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ',
'Vanuatu':'VU','Vatican':'VA','Venezuela':'VE','Vietnam':'VN',
'W. Sahara':'EH','Yemen':'YE','Zambia':'ZM','Zimbabwe':'ZW',
'eSwatini':'SZ','Åland':'AX',
};
const _features = feature(worldData, worldData.objects.countries).features;
export const countryNames = _features
.map(f => f.properties?.name).filter(Boolean).sort();
// country name → topojson numeric ID (e.g. 'Japan' → '392')
export const nameToId = Object.fromEntries(
_features
.filter(f => f.properties?.name && f.id)
.map(f => [f.properties.name, String(f.id)])
);
nameToId['Kosovo'] = 'XK';
/** @param {string} country */
export function flagEmoji(country) {
const code = nameToAlpha2[country];
if (!code) return '';
return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join('');
}

View File

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,45 @@
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);
}

View File

@@ -0,0 +1,454 @@
<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

@@ -0,0 +1,465 @@
<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

@@ -0,0 +1,88 @@
<script>
let { entry, onConfirm, onCancel } = $props();
</script>
<div class="overlay" role="dialog" aria-modal="true">
<div class="dialog">
<h2 class="title">Delete trip?</h2>
<p class="body">
<strong>{entry.location.cities.join(', ')}, {entry.location.country}</strong>{entry.date.slice(0, 4)} will be permanently removed.
</p>
<div class="actions">
<button class="btn btn-cancel" onclick={onCancel}>Cancel</button>
<button class="btn btn-delete" onclick={onConfirm}>Delete</button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 32px;
width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
}
.title {
font-size: 17px;
font-weight: 400;
color: var(--text-h);
margin-bottom: 10px;
}
.body {
font-size: 14px;
font-weight: 300;
color: var(--text);
line-height: 1.6;
margin-bottom: 24px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
padding: 8px 18px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-cancel {
background: var(--bg);
color: var(--text);
}
.btn-cancel:hover {
background: var(--bg-subtle);
color: var(--text-h);
}
.btn-delete {
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
.btn-delete:hover {
background: #b91c1c;
border-color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,458 @@
<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,352 @@
<script>
import { removeJournal } from '../stores/journalStore.js';
import { flagEmoji } from '../shared/countries.js';
import DeleteConfirm from './DeleteConfirm.svelte';
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
let { entry, onBack, onEdit } = $props();
let showDeleteConfirm = $state(false);
function handleDelete() {
removeJournal(entry.id);
onBack();
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
}
let lightboxSrc = $state(null);
</script>
{#if showDeleteConfirm}
<DeleteConfirm {entry} onConfirm={handleDelete} onCancel={() => showDeleteConfirm = false} />
{/if}
<!-- Lightbox -->
{#if lightboxSrc}
<div class="lightbox" onclick={() => lightboxSrc = null} role="button" tabindex="0"
onkeydown={(e) => e.key === 'Escape' && (lightboxSrc = null)}>
<img src={lightboxSrc} alt="" />
</div>
{/if}
<div class="detail-layout">
<!-- ── Full-width top bar ── -->
<header class="detail-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 class="topbar-divider"></div>
<span class="topbar-flag">{flagEmoji(entry.location.country)}</span>
<div class="topbar-place">
<span class="topbar-city">{entry.location.cities.join(', ')}</span>
<span class="topbar-country">{entry.location.country}</span>
</div>
</div>
<div class="topbar-right">
<button class="topbar-btn" title="Edit entry" onclick={onEdit}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit
</button>
<button class="topbar-btn topbar-btn--danger" title="Delete entry" onclick={() => showDeleteConfirm = true}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
</svg>
Delete
</button>
</div>
</header>
<!-- ── Body: photo left + Q&A right ── -->
<div class="detail-body">
<!-- Left: photos -->
<div class="photo-col">
<div class="photo-scroll">
{#if entry.photos.length === 0}
<div class="no-photos">No photos</div>
{:else}
<div class="photo-grid">
{#each entry.photos as photo, i}
<div class="photo-cell" class:cell-wide={i === 0 && entry.photos.length > 1}>
<img src={photo} alt=""
onclick={() => lightboxSrc = photo}
onerror={(e) => e.currentTarget.parentElement.classList.add('cell-broken')} />
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Right: Q&A -->
<div class="info-col">
<div class="info-inner">
<div class="qa-list">
<div class="qa-item">
<p class="question">When did you go?</p>
<p class="answer">{formatDate(entry.date)}</p>
</div>
<div class="qa-item">
<p class="question">How long did you stay?</p>
<p class="answer">{entry.days} {entry.days === 1 ? 'day' : 'days'}</p>
</div>
<div class="qa-item">
<p class="question">Who did you go with?</p>
<p class="answer">
{#if entry.tripType === 'solo'}
Just me — solo trip
{:else if entry.tripType === 'family'}
With family
{:else}
With friends
{/if}
</p>
</div>
<div class="qa-item">
<p class="question">How was it?</p>
<p class="answer memo">{entry.memo}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
/* ── Outer layout: column (topbar + body) ── */
.detail-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── Full-width top bar ── */
.detail-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 52px;
flex-shrink: 0;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.topbar-divider {
width: 1px;
height: 20px;
background: var(--border);
}
.topbar-flag { font-size: 20px; line-height: 1; }
.topbar-place {
display: flex;
align-items: baseline;
gap: 6px;
}
.topbar-city {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
}
.topbar-country {
font-size: 17px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
}
.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--danger:hover { color: #dc2626; background: #fff1f1; border-color: #fca5a5; }
/* ── Body row ── */
.detail-body {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
}
/* ── Left: photos ── */
.photo-col {
flex: 1;
overflow: hidden;
background: #f0f0f0;
display: flex;
flex-direction: column;
}
.photo-scroll {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.photo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
grid-auto-rows: 200px;
}
.photo-cell {
overflow: hidden;
background: var(--bg-subtle);
border-radius: 4px;
cursor: zoom-in;
}
.photo-cell.cell-wide {
grid-column: 1 / -1;
grid-row: span 2;
}
.photo-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.2s ease;
}
.photo-cell:hover img { transform: scale(1.03); }
.photo-cell.cell-broken {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-sub);
font-size: 12px;
}
.no-photos {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-sub);
font-size: 14px;
}
/* ── Right: Q&A ── */
.info-col {
width: 440px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: var(--bg);
}
.info-inner {
padding: 36px 32px 80px;
}
.qa-list {
display: flex;
flex-direction: column;
}
.qa-item {
padding: 20px 0;
border-bottom: 1px solid var(--border);
}
.qa-item:first-child { padding-top: 0; }
.qa-item:last-child { border-bottom: none; }
.question {
font-size: 11px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 8px;
}
.answer {
font-size: 15px;
font-weight: 300;
color: var(--text-h);
line-height: 1.5;
}
.answer.memo {
font-size: 14px;
color: var(--text);
line-height: 1.75;
}
/* ── Lightbox ── */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.lightbox img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
}
/* ── Responsive ── */
@media (max-width: 700px) {
.detail-body { flex-direction: column; overflow-y: auto; }
.photo-col { height: 260px; flex: none; }
.info-col { width: 100%; border-left: none; border-top: 1px solid var(--border); }
}
</style>

View File

@@ -0,0 +1,198 @@
<script>
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[] }} */
let { entries } = $props();
let stats = $derived.by(() => {
if (entries.length === 0) return null;
const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))];
const cities = [...new Set(entries.flatMap(e => e.location.cities))];
const years = entries.map(e => new Date(e.date).getFullYear());
const minYear = Math.min(...years);
const maxYear = Math.max(...years);
const yearRange = minYear === maxYear ? `${minYear}` : `${minYear} ${maxYear}`;
return { totalDays, countries, cities, yearRange, tripCount: entries.length };
});
</script>
{#if stats}
<div class="passport">
<!-- diagonal pattern -->
<div class="passport-body">
<!-- Left -->
<div class="passport-left">
<div class="passport-header">
<svg viewBox="0 0 32 32" fill="none" class="globe">
<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.3"/>
<ellipse cx="16" cy="16" rx="5.5" ry="13" stroke="currentColor" stroke-width="1.3"/>
<line x1="3" y1="16" x2="29" y2="16" stroke="currentColor" stroke-width="1.3"/>
<line x1="5" y1="9" x2="27" y2="9" stroke="currentColor" stroke-width="1.3"/>
<line x1="5" y1="23" x2="27" y2="23" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span class="issuer">TRAVEL JOURNAL</span>
</div>
<div>
<p class="type">PASSPORT</p>
<p class="years">{stats.yearRange}</p>
</div>
</div>
<div class="vdivider"></div>
<!-- Right -->
<div class="passport-right">
<div class="field">
<span class="field-label">TRIPS</span>
<span class="field-value">{stats.tripCount}</span>
</div>
<div class="field">
<span class="field-label">COUNTRIES</span>
<span class="field-value">{stats.countries.length}</span>
</div>
<div class="field">
<span class="field-label">DAYS</span>
<span class="field-value">{stats.totalDays}</span>
</div>
</div>
</div>
<!-- MRZ -->
<div class="mrz">
<span>P&lt;JNL{String(stats.tripCount).padStart(2,'0')}&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</span>
<span>{stats.yearRange.replace(' ','').replace(/\s/g,'')}{'<'.repeat(12)}{String(stats.totalDays).padStart(4,'0')}</span>
</div>
</div>
{/if}
<style>
.passport {
background: #1e1b4b;
border-radius: 14px;
overflow: hidden;
color: #e0e7ff;
position: relative;
}
.passport::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
135deg,
transparent 0px, transparent 20px,
rgba(255,255,255,0.025) 20px, rgba(255,255,255,0.025) 21px
);
pointer-events: none;
}
/* Body: left + divider + right in a row */
.passport-body {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 20px;
gap: 0;
position: relative;
z-index: 1;
}
/* Left column */
.passport-left {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
padding-right: 20px;
}
.passport-header {
display: flex;
align-items: center;
gap: 8px;
}
.globe {
width: 26px;
height: 26px;
color: #a5b4fc;
flex-shrink: 0;
}
.issuer {
font-size: 9px;
font-weight: 500;
letter-spacing: 0.18em;
color: #a5b4fc;
line-height: 1.4;
}
.type {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.22em;
color: #818cf8;
margin-bottom: 4px;
}
.years {
font-size: 26px;
font-weight: 400;
color: #fff;
letter-spacing: -0.8px;
line-height: 1;
}
/* Divider */
.vdivider {
width: 1px;
background: rgba(255,255,255,0.12);
flex-shrink: 0;
align-self: stretch;
}
/* Right column */
.passport-right {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
padding-left: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 2px;
}
.field-label {
font-size: 8px;
font-weight: 500;
letter-spacing: 0.18em;
color: #818cf8;
}
.field-value {
font-size: 22px;
font-weight: 400;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
}
/* MRZ strip */
.mrz {
border-top: 1px solid rgba(255,255,255,0.1);
padding: 9px 20px;
background: rgba(0,0,0,0.18);
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
z-index: 1;
}
.mrz span {
font-family: var(--mono);
font-size: 8px;
color: #6366f1;
letter-spacing: 0.06em;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,484 @@
<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, onSaved = 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 },
});
onSaved();
} 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} onblurcommit={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; }
.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; }
.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

@@ -0,0 +1,186 @@
<script>
import { storage } from '../firebase.js';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
let { photos, onchange } = $props();
let fileInput;
let uploading = $state(false);
function remove(index) {
onchange(photos.filter((_, i) => i !== index));
}
async function addFiles(e) {
const files = Array.from(e.currentTarget.files ?? []);
if (!files.length) return;
uploading = true;
try {
const urls = await Promise.all(files.map(uploadPhoto));
onchange([...photos, ...urls]);
} finally {
uploading = false;
e.currentTarget.value = '';
}
}
/** @param {File} file */
async function uploadPhoto(file) {
const storageRef = ref(storage, `photos/${crypto.randomUUID()}`);
await uploadBytes(storageRef, file);
return getDownloadURL(storageRef);
}
</script>
<div class="photo-editor">
<div class="label-row">
<span class="label">Photos</span>
<input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden />
</div>
{#if photos.length === 0}
<button type="button" class="empty-zone" onclick={() => fileInput.click()} disabled={uploading}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<span>{uploading ? 'Uploading…' : 'Click to add photos'}</span>
</button>
{:else}
<div class="grid">
{#each photos as src, i (src + i)}
<div class="cell">
<img {src} alt="photo {i + 1}" />
<button type="button" class="remove-btn" onclick={() => remove(i)} aria-label="Remove photo">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
<button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
</div>
{/if}
</div>
<style>
.photo-editor {
display: flex;
flex-direction: column;
gap: 10px;
}
.label-row {
display: flex;
align-items: center;
gap: 10px;
}
.label {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-sub);
flex: 1;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--sans);
font-size: 12px;
font-weight: 300;
color: var(--accent);
background: var(--accent-bg);
border: 1px solid var(--accent-border);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
transition: background 0.15s;
}
.add-btn:hover { background: rgba(124,58,237,0.12); }
.empty-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
height: 120px;
border: 1.5px dashed var(--border-bright);
border-radius: 10px;
color: var(--text-sub);
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
cursor: pointer;
background: var(--bg-subtle);
transition: border-color 0.15s, color 0.15s;
width: 100%;
}
.empty-zone:hover { border-color: var(--accent-border); color: var(--accent); }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.cell {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: var(--bg-subtle);
}
.cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.remove-btn {
position: absolute;
top: 5px;
right: 5px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.cell:hover .remove-btn { opacity: 1; }
.remove-btn:hover { background: rgba(220,38,38,0.85); }
.add-cell {
aspect-ratio: 1;
border-radius: 8px;
border: 1.5px dashed var(--border-bright);
background: var(--bg-subtle);
color: var(--text-sub);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
</style>

View File

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

View File

@@ -0,0 +1,235 @@
<script>
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClick: () => void }} */
let { entries, onClick } = $props();
const continentMap = {
'Japan':'Asia','South Korea':'Asia','China':'Asia','Thailand':'Asia','Vietnam':'Asia',
'Indonesia':'Asia','Malaysia':'Asia','Singapore':'Asia','India':'Asia','Taiwan':'Asia',
'Philippines':'Asia','Cambodia':'Asia','Nepal':'Asia',
'France':'Europe','Spain':'Europe','Italy':'Europe','Germany':'Europe','UK':'Europe',
'Netherlands':'Europe','Portugal':'Europe','Greece':'Europe','Sweden':'Europe',
'Norway':'Europe','Denmark':'Europe','Finland':'Europe','Switzerland':'Europe',
'Austria':'Europe','Belgium':'Europe','Poland':'Europe','Czech Republic':'Europe',
'Hungary':'Europe','Croatia':'Europe','Turkey':'Europe',
'USA':'N. America','Canada':'N. America','Mexico':'N. America',
'Brazil':'S. America','Argentina':'S. America','Chile':'S. America','Peru':'S. America',
'Australia':'Oceania','New Zealand':'Oceania',
'Morocco':'Africa','Egypt':'Africa','Kenya':'Africa','South Africa':'Africa',
};
const continentColors = {
'Asia':'#f87171','Europe':'#818cf8','N. America':'#4ade80',
'S. America':'#fbbf24','Africa':'#fb923c','Oceania':'#c084fc',
};
let stats = $derived.by(() => {
const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))];
const contDays = {};
for (const e of entries) {
const c = continentMap[e.location.country] ?? 'Other';
contDays[c] = (contDays[c] ?? 0) + e.days;
}
const top = Object.entries(contDays).sort((a, b) => b[1] - a[1]);
return { totalDays, countries, contDays, top, trips: entries.length };
});
</script>
<button class="preview-card" onclick={onClick} aria-label="Share your journey">
<div class="pc-bg"></div>
<div class="pc-grid-pattern"></div>
<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">
<span class="pc-num">{stats.totalDays}</span>
<span class="pc-label">days traveled</span>
</div>
<div class="pc-row">
<div class="pc-stat">
<span class="pc-stat-num">{stats.countries.length}</span>
<span class="pc-stat-label">countries</span>
</div>
<div class="pc-stat">
<span class="pc-stat-num">{stats.trips}</span>
<span class="pc-stat-label">trips</span>
</div>
<div class="pc-stat">
<span class="pc-stat-num">{Object.keys(stats.contDays).length}</span>
<span class="pc-stat-label">continents</span>
</div>
</div>
{#if stats.top.length > 0}
<div class="pc-bar-wrap">
<div class="pc-bar">
{#each stats.top as [cont, days]}
<div class="pc-seg" style="flex:{days}; background:{continentColors[cont] ?? '#818cf8'}"></div>
{/each}
</div>
<div class="pc-bar-labels">
{#each stats.top.slice(0,3) as [cont, days]}
<span class="pc-bar-label" style="color:{continentColors[cont] ?? '#818cf8'}">{cont}</span>
{/each}
</div>
</div>
{/if}
<div class="pc-cta">Share your journey →</div>
</button>
<style>
.preview-card {
position: sticky;
top: 40px;
width: 100%;
background: #1a1630;
border-radius: 12px;
overflow: hidden;
color: #fff;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.06);
text-align: left;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
font-family: var(--sans);
}
.preview-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(124,58,237,0.12);
}
.pc-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 90% 0%, rgba(124,58,237,0.2) 0%, transparent 60%),
radial-gradient(ellipse 60% 60% at 0% 100%, rgba(99,102,241,0.1) 0%, transparent 60%);
pointer-events: none;
}
.pc-grid-pattern {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
}
.pc-header {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.pc-brand {
font-size: 8px;
font-weight: 500;
letter-spacing: 0.2em;
color: #a5b4fc;
}
.pc-share-icon { color: #a5b4fc; flex-shrink: 0; }
.pc-hero {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.pc-num {
font-size: 40px;
font-weight: 400;
line-height: 1;
letter-spacing: -1.5px;
color: #fff;
}
.pc-label {
font-size: 11px;
font-weight: 300;
color: #a5b4fc;
letter-spacing: 0.04em;
}
.pc-row {
display: flex;
gap: 0;
position: relative;
z-index: 1;
}
.pc-stat {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 12px;
border-right: 1px solid rgba(255,255,255,0.08);
}
.pc-stat:last-child { border-right: none; padding-right: 0; padding-left: 12px; }
.pc-stat:not(:first-child):not(:last-child) { padding-left: 12px; }
.pc-stat-num {
font-size: 16px;
font-weight: 400;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
}
.pc-stat-label {
font-size: 9px;
font-weight: 300;
color: rgba(255,255,255,0.4);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.pc-bar-wrap {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.pc-bar {
display: flex;
height: 4px;
border-radius: 2px;
overflow: hidden;
gap: 2px;
}
.pc-seg { border-radius: 2px; min-width: 3px; }
.pc-bar-labels {
display: flex;
gap: 10px;
}
.pc-bar-label {
font-size: 9px;
font-weight: 300;
letter-spacing: 0.04em;
}
.pc-cta {
position: relative;
z-index: 1;
font-size: 11px;
font-weight: 400;
color: #a5b4fc;
letter-spacing: 0.04em;
padding-top: 4px;
border-top: 1px solid rgba(255,255,255,0.08);
}
</style>

View File

@@ -0,0 +1,338 @@
<script>
import { flagEmoji } from '../shared/countries.js';
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onClick: () => void }} */
let { entry, onClick } = $props();
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);
let thumbPhotos = $derived(entry.photos.slice(1, 4));
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
const transportIcons = {
flight: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.18 10a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.09 0h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 8a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 15z"/></svg>`,
train: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="16" rx="2"/><path d="M4 12h16M8 20l-2 2M16 20l2 2M12 12v6"/><circle cx="8.5" cy="7.5" r="1"/><circle cx="15.5" cy="7.5" r="1"/></svg>`,
bus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6v6M15 6v6M2 12h19.6M18 18h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2m0 0v2m8-2v2M6 18h12"/><circle cx="8" cy="18" r="2"/><circle cx="16" cy="18" r="2"/></svg>`,
car: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v9a2 2 0 0 1-2 2h-2"/><circle cx="7.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></svg>`,
ship: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1 .6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-9-4-9 4c0 2.9.94 5.34 2.81 7.76"/><path d="M19 13V7a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v6"/><path d="M12 10v4"/><path d="M12 3v4"/></svg>`,
walk: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4" r="2"/><path d="m9 22 1-7-2.5-2.5L9 9l6 1-1 4.5L16 22"/><path d="M6.5 11.5 4 12l2 6"/><path d="M17.5 11.5 20 12l-2 6"/></svg>`,
};
let transportLabel = $derived({ flight: 'Flight', train: 'Train', bus: 'Bus', car: 'Car', ship: 'Ship', walk: 'Walk' }[entry.transport] ?? '');
</script>
<li class="v-item">
<div class="v-dot" aria-hidden="true"></div>
<div class="v-content">
<!-- Country above card -->
<div class="above-card">
<span class="flag">{flagEmoji(entry.location.country)}</span>
<span class="country-name">{entry.location.country}</span>
</div>
<!-- Card -->
<div class="entry-card" role="button" tabindex="0"
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}
</div>
{#if thumbPhotos.length > 0}
<div class="photo-thumbs">
{#each thumbPhotos as photo, i}
<div class="photo-thumb">
<img src={photo} alt="" loading="lazy"
onerror={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling.style.display = 'flex';
}} />
<div class="thumb-fallback" style="display:none">
<svg width="16" height="16" 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 i === 2 && extraCount > 0}
<div class="extra-overlay">+{extraCount}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Info bar -->
<div class="card-info">
<span class="city">{entry.location.cities.join(', ')}</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>
</div>
</div>
</div>
</div>
</li>
<style>
/* ── Timeline row ── */
.v-item {
display: flex;
gap: 14px;
align-items: flex-start;
padding-bottom: 48px;
position: relative;
}
.v-item:last-child { padding-bottom: 0; }
.v-item:not(:last-child)::after {
content: '';
position: absolute;
left: 4px;
top: 34px;
bottom: 0;
width: 1px;
background: var(--border);
}
.v-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--bg);
border: 1.5px solid var(--accent);
margin-top: 6px;
z-index: 1;
}
/* ── Content column (above-card + card) ── */
.v-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
/* ── Country above card ── */
.above-card {
display: flex;
align-items: center;
gap: 6px;
padding-left: 2px;
}
.flag { font-size: 16px; line-height: 1; }
.country-name {
font-size: 15px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.2px;
}
/* ── Card ── */
.entry-card {
position: relative;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-raised);
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
cursor: pointer;
overflow: hidden;
transition: box-shadow 0.2s, transform 0.15s;
container-type: inline-size;
container-name: card;
}
.entry-card:hover {
box-shadow: 0 6px 20px rgba(0,0,0,0.09);
transform: translateY(-2px);
}
/* ── Trip badge — absolute top-right of card ── */
.trip-badge {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
font-size: 11px;
font-weight: 300;
padding: 3px 10px;
border-radius: 20px;
letter-spacing: 0.04em;
backdrop-filter: blur(6px);
}
.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; }
/* ── Photo grid — fixed height, always consistent ── */
.photo-grid {
display: grid;
grid-template-columns: 1fr;
height: 180px;
background: var(--bg-subtle);
}
.photo-grid.has-thumbs {
grid-template-columns: 2fr 1fr;
gap: 2px;
}
@container card (max-width: 300px) {
.photo-grid.has-thumbs { grid-template-columns: 1fr; }
.photo-thumbs { display: none; }
}
/* ── Main photo ── */
.photo-main {
overflow: hidden;
height: 100%;
background: var(--bg-subtle);
}
.photo-main img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-sub);
}
/* ── Thumbs ── */
.photo-thumbs {
display: flex;
flex-direction: column;
gap: 2px;
}
.photo-thumb {
position: relative;
flex: 1;
overflow: hidden;
min-height: 0;
background: var(--bg-subtle);
}
.photo-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-sub);
background: var(--bg-subtle);
}
.extra-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 400;
color: #fff;
}
/* ── Info bar ── */
.card-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg);
border-top: 1px solid var(--border);
gap: 8px;
min-height: 44px;
}
.city {
font-size: 13px;
font-weight: 300;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 300;
color: var(--text-sub);
white-space: nowrap;
flex-shrink: 0;
}
.dot-sep { color: var(--border-bright); }
.transport-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 400;
padding: 2px 7px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-sub);
}
.transport-chip--flight { color: #7c3aed; background: rgba(124,58,237,0.07); border-color: rgba(124,58,237,0.2); }
.transport-chip--train { color: #0369a1; background: rgba(3,105,161,0.07); border-color: rgba(3,105,161,0.2); }
.transport-chip--bus { color: #15803d; background: rgba(21,128,61,0.07); border-color: rgba(21,128,61,0.2); }
.transport-chip--car { color: #b45309; background: rgba(180,83,9,0.07); border-color: rgba(180,83,9,0.2); }
.transport-chip--ship { color: #0e7490; background: rgba(14,116,144,0.07); border-color: rgba(14,116,144,0.2); }
.transport-chip--walk { color: #65a30d; background: rgba(101,163,13,0.07); border-color: rgba(101,163,13,0.2); }
</style>

View File

@@ -0,0 +1,40 @@
<script>
const sortOptions = [
{ value: 'date-desc', label: 'Newest first' },
{ value: 'date-asc', label: 'Oldest first' },
{ value: 'country-asc', label: 'Country A → Z' },
{ value: 'country-desc', label: 'Country Z → A' },
];
let { sortKey, onSort } = $props();
</script>
<div class="toolbar"></div>
<style>
.toolbar {
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
select {
font-family: var(--sans);
font-size: var(--text-xs);
font-weight: 300;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 28px 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-subtle);
color: var(--text);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2352525b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
transition: border-color 0.15s, color 0.15s;
}
select:hover { border-color: var(--border-bright); color: var(--text-h); }
select:focus { outline: 1px solid var(--accent-border); outline-offset: 2px; }
</style>

View File

@@ -0,0 +1,278 @@
<script>
import { get } from 'svelte/store';
import { journals } from '../stores/journalStore.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 ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte';
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {}, onGoToMap = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null));
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
let showShare = $state(false);
let newEntryCountry = $state('');
// When App passes a country from the map, capture it locally before clearing
$effect(() => {
if (pendingCountry) {
newEntryCountry = pendingCountry;
selectedId = null;
view = 'new';
onNewEntryClear();
}
});
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 sortKey = $state('date-desc');
let sortedEntries = $state(/** @type {typeof entries} */([]));
$effect(() => {
const key = sortKey;
sortedEntries = [...entries].sort((a, b) => {
if (key === 'date-asc') return a.date.localeCompare(b.date);
if (key === 'date-desc') return b.date.localeCompare(a.date);
if (key === 'country-asc') return a.location.country.localeCompare(b.location.country) || b.date.localeCompare(a.date);
if (key === 'country-desc') return b.location.country.localeCompare(a.location.country) || b.date.localeCompare(a.date);
return 0;
});
});
function getYear(iso) {
return new Date(iso).getFullYear();
}
</script>
<div class="journal-page">
{#if view === 'new'}
<div class="detail-scroll">
<NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} onSaved={() => { onGoToMap(); }} />
</div>
{:else if view === 'edit' && selected}
<div class="detail-scroll">
<EditForm entry={selected} onBack={() => { view = 'detail'; }} />
</div>
{:else if view === 'detail' && selected}
<div class="detail-scroll">
<JournalDetail
entry={selected}
onBack={() => { selectedId = null; view = 'list'; onDetailChange(false); }}
onEdit={() => { view = 'edit'; }}
/>
</div>
{:else}
<div class="right-panel">
<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>
{#if sortedEntries.length > 0}
<div class="share-row">
<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}
</div>
{#if showShare}
<ShareCard entries={sortedEntries} onClose={() => (showShare = false)} />
{/if}
<style>
.journal-page {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
height: 100%;
overflow: hidden;
}
/* ── Right panel ── */
.right-panel {
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;
}
.share-row {
margin-bottom: 24px;
}
@media (max-width: 760px) {
.center-col {
padding: 32px 24px 60px;
}
}
/* ── Detail view ── */
.detail-scroll {
flex: 1;
overflow-y: auto;
background: var(--bg);
}
/* ── Timeline list ── */
.v-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
/* Year marker */
.year-marker {
padding: 24px 0 14px;
}
.year-label {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.04em;
color: #7c3aed;
border-left: 3px solid #7c3aed;
padding-left: 12px;
}
.page-footer {
margin-top: 56px;
text-align: center;
font-size: 11px;
font-weight: 300;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-sub);
padding-top: 24px;
border-top: 1px solid var(--border);
}
.empty { text-align: center; color: var(--text-sub); padding: 80px 0; }
.sort-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
padding-top: 20px;
}
.sort-label {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-sub);
}
.sort-select {
font-family: var(--sans);
font-size: 11px;
font-weight: 300;
letter-spacing: 0.04em;
padding: 4px 24px 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-subtle);
color: var(--text);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2352525b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.sort-select:hover { border-color: var(--border-bright); color: var(--text-h); }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: var(--text-xl);
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0;
}
.new-btn {
display: inline-flex;
align-items: center;
gap: 6px;
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;
flex-shrink: 0;
}
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
</style>

View File

@@ -0,0 +1,391 @@
<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

@@ -0,0 +1,435 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
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';
let { onclose, onprogress } = $props();
const HOME_CODE = '203';
const TRANSPORT_IMG = {
flight: airplaneImg,
train: trainImg,
bus: busImg,
car: carImg,
ship: shipImg,
walk: walkImg,
};
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, transport = 'flight') {
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;
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);
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;
// 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;
}
// Use real journal entries, sorted by date
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,
transport: e.transport ?? 'flight',
})).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' },
];
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, trip.transport);
}
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;
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;
}
</style>

View File

@@ -1,14 +1,16 @@
<script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
import { getSelected } from '../layout/selection.svelte.js';
let hoveredSeg = $state(null);
const continentColors = {
'Europe': '#3b82f6',
'Asia': '#ef4444',
'Africa': '#f97316',
'N. America': '#22c55e',
'S. America': '#eab308',
'Oceania': '#a855f7'
'Europe': '#6366f1',
'Asia': '#f43f5e',
'Africa': '#fb923c',
'N. America': '#06b6d4',
'S. America': '#f59e0b',
'Oceania': '#8b5cf6'
};
let counts = $derived.by(() => {
@@ -25,180 +27,229 @@
let grandTotal = $derived(Object.values(continentTotals).reduce((a, b) => a + b, 0));
let pct = $derived(grandTotal > 0 ? Math.round(total / grandTotal * 100) : 0);
let donutStyle = $derived.by(() => {
if (total === 0) return 'background: #e2e8f0';
let segments = $derived.by(() => {
if (total === 0) return [];
const segs = [];
let deg = 0;
const stops = [];
for (const cont of CONTINENTS) {
const angle = counts[cont] / total * 360;
const angle = Math.min(counts[cont] / total * 360, 359.99);
if (angle > 0) {
stops.push(`${continentColors[cont]} ${deg}deg ${deg + angle}deg`);
const startDeg = deg;
const endDeg = deg + angle;
const sr = (startDeg - 90) * Math.PI / 180;
const er = (endDeg - 90) * Math.PI / 180;
const cx = 50, cy = 50, outerR = 44, innerR = 22;
const x1 = cx + outerR * Math.cos(sr);
const y1 = cy + outerR * Math.sin(sr);
const x2 = cx + outerR * Math.cos(er);
const y2 = cy + outerR * Math.sin(er);
const x3 = cx + innerR * Math.cos(er);
const y3 = cy + innerR * Math.sin(er);
const x4 = cx + innerR * Math.cos(sr);
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 });
deg += angle;
}
}
return `background: conic-gradient(${stops.join(', ')})`;
return segs;
});
</script>
<div class="panel">
<h2 class="headline">your statistics</h2>
<span class="bar-label">visited countries</span>
<div class="total-bar-wrap">
<div class="total-bar-bg">
<div class="total-bar-fill" style="width: {pct}%"></div>
</div>
<span class="total-bar-text">{total} / {grandTotal}</span>
<div class="card">
<!-- count -->
<div class="stat-block">
<span class="big-num">{total}</span>
<span class="stat-sub">countries visited</span>
</div>
<div class="divider"></div>
<div class="vdivider"></div>
<span class="bar-label">by continent</span>
{#each CONTINENTS as continent}
{@const contTotal = continentTotals[continent]}
<div class="row">
<span class="dot" style="background: {continentColors[continent]}"></span>
<span class="label">{continent}</span>
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
</div>
{/each}
<!-- world % -->
<div class="stat-block">
<span class="big-num accent">{pct}%</span>
<span class="stat-sub">of the world</span>
</div>
<div class="donut-wrap">
<div class="donut" style={donutStyle}>
<div class="donut-hole"></div>
<div class="vdivider"></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>
</div>
{:else}
<span class="hint">hover a slice</span>
{/if}
</div>
</div>
<div class="divider"></div>
<div class="vdivider"></div>
<div class="disclaimer">Contains all UN countries, Kosovo, Hong Kong and Taiwan</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>
<span class="disclaimer">All UN countries · Kosovo · HK · Taiwan</span>
</div>
</div>
<style>
.panel {
flex: 0 0 min(360px, 25vw);
background: #f8fafc;
border-left: 1px solid #dce8f0;
padding: 24px 28px;
overflow-y: auto;
font-family: sans-serif;
}
.headline {
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #1f2937;
margin: 0 0 20px 0;
}
.bar-label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
display: block;
margin-bottom: 8px;
}
.total-bar-wrap {
.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);
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.total-bar-bg {
flex: 1;
height: 20px;
background: #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.total-bar-fill {
height: 100%;
background: #3b82f6;
border-radius: 10px;
transition: width 0.3s ease;
min-width: 0;
}
.total-bar-text {
font-size: 15px;
font-weight: 700;
color: #1f2937;
gap: 0;
padding: 0 4px;
height: 110px;
z-index: 10;
font-family: var(--sans);
white-space: nowrap;
}
.divider {
height: 1px;
background: #e2e8f0;
margin: 16px 0;
}
.row {
.stat-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 7px 0;
gap: 4px;
padding: 0 36px;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
.big-num {
font-size: 40px;
font-weight: 300;
letter-spacing: -2px;
color: var(--text-h);
line-height: 1;
}
.big-num.accent { color: var(--accent); }
.stat-sub {
font-size: 12px;
font-weight: 300;
color: var(--text-sub);
letter-spacing: 0.03em;
}
.vdivider {
width: 1px;
height: 56px;
background: var(--border);
flex-shrink: 0;
}
.label {
flex: 1;
font-size: 14px;
color: #334155;
}
.value {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.total {
font-weight: 350;
color: #94a3b8;
font-size: 13px;
}
.donut-wrap {
/* donut */
.donut-block {
display: flex;
justify-content: center;
margin: 24px 0;
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;
}
.donut {
width: 130px;
height: 130px;
border-radius: 50%;
.tooltip {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
outline: 2px solid #fff;
gap: 7px;
font-size: 13px;
}
.donut-hole {
width: 60px;
height: 60px;
.tooltip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #f8fafc;
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;
text-transform: uppercase;
color: var(--text-sub);
}
.hint {
font-size: 12px;
color: var(--text-sub);
opacity: 0.45;
}
/* 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;
overflow: hidden;
margin-bottom: 10px;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #a78bfa);
border-radius: 4px;
transition: width 0.3s ease;
min-width: 0;
}
.disclaimer {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
text-align: center;
color: var(--text-sub);
opacity: 0.5;
letter-spacing: 0.02em;
}
</style>

View File

@@ -3,7 +3,9 @@
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
import { getSelected, toggle, setTotalCount } from '../layout/selection.svelte.js';
import { getSelected, setTotalCount } from '../layout/selection.svelte.js';
let { onCountryClick = (_name) => {} } = $props();
const TERRITORY_PARENT = {
'016': '840', // American Samoa -> United States
@@ -49,7 +51,30 @@
return TERRITORY_PARENT[d.id] || d.id;
}
const HOME_COLOR = '#8b5cf6';
const HOME_COLOR_HOVER = '#7c3aed';
const VISITED_COLOR = '#22c55e';
const VISITED_COLOR_HOVER = '#16a34a';
const UNVISITED_COLOR = '#ffffff';
const UNVISITED_COLOR_HOVER = '#f0f6fa';
function countryColor(d, sel, homeCode) {
const id = effId(d);
if (!sel.has(id)) return UNVISITED_COLOR;
if (id === homeCode) return HOME_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;
return VISITED_COLOR_HOVER;
}
let frameEl;
let _paths = null;
let _g = null;
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
@@ -57,6 +82,15 @@
proj.scale(s).translate([w / 2, h * 0.70]);
}
function updateAllFills() {
const sel = getSelected();
if (!_paths || !_g) return;
_paths.attr('fill', d => countryColor(d, sel, null));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, null));
}
$effect(updateAllFills);
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
@@ -81,26 +115,21 @@
.attr('width', width)
.attr('height', height);
const g = svg.append('g');
_g = svg.append('g');
const tooltip = d3.select(frameEl)
.append('div')
.attr('class', 'tooltip')
.style('display', 'none');
function updateFill(sel) {
sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
}
function attachEvents(sel) {
sel
.on('click', (event, d) => {
toggle(effId(d));
updateFill(d3.select(event.currentTarget));
onCountryClick(d.properties.name);
})
.on('mouseenter', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');
const s = getSelected();
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, null));
tooltip.style('display', 'block').text(d.properties.name);
})
.on('mousemove', (event) => {
@@ -108,35 +137,36 @@
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
})
.on('mouseleave', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
const s = getSelected();
d3.select(event.currentTarget).attr('fill', countryColor(d, s, null));
tooltip.style('display', 'none');
});
}
const paths = g.selectAll('path')
_paths = _g.selectAll('path')
.data(countries)
.join('path')
.attr('d', path)
.attr('fill', '#ffffff')
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5);
attachEvents(paths);
attachEvents(_paths);
function renderMicrostates() {
g.selectAll('.micro-state').remove();
_g.selectAll('.micro-state').remove();
const threshold = Math.max(4, 16 / d3.zoomTransform(svg.node()).k);
paths.each(function (d) {
_paths.each(function (d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d);
const c = g.append('circle')
const c = _g.append('circle')
.attr('class', 'micro-state')
.datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 3)
.attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff')
.attr('r', 2)
.attr('fill', countryColor(d, getSelected(), null))
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c);
@@ -149,7 +179,7 @@
const zoom = d3.zoom()
.scaleExtent([1, 32])
.on('zoom', (event) => {
g.attr('transform', event.transform);
_g.attr('transform', event.transform);
renderMicrostates();
});
@@ -166,7 +196,7 @@
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height);
const countryPaths = g.selectAll('path');
const countryPaths = _g.selectAll('path');
countryPaths.attr('d', path);
updateFill(countryPaths);
renderMicrostates();

View File

@@ -210,4 +210,3 @@ for (const id of Object.keys(map)) {
export function getContinent(id) {
return map[id] ?? null;
}

View File

@@ -1,7 +1,6 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
})