2 Commits

Author SHA1 Message Date
haerikimmm
500ad347ee changed the collapsible panel to top centered layout with consistent ui style 2026-06-15 17:15:51 +09:00
haerikimmm
4c219abab4 changed to centered layout 2026-06-15 16:43:32 +09:00
55 changed files with 598 additions and 3900 deletions

1
.gitignore vendored
View File

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

View File

View File

@@ -1,11 +0,0 @@
> map-journal@0.0.0 dev
> vite
Port 5173 is in use, trying another one...
Port 5174 is in use, trying another one...
VITE v8.0.15 ready in 1792 ms
➜ Local: http://localhost:5175/
 ➜ Network: use --host to expose

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Journi</title> <title>Journi</title>
</head> </head>

View File

@@ -23,7 +23,7 @@
* Typecheck JS in `.svelte` and `.js` files by default. * Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types. * Disable this if you'd like to use dynamic types.
*/ */
"checkJs": false "checkJs": true
}, },
/** /**
* Use global.d.ts instead of compilerOptions.types * Use global.d.ts instead of compilerOptions.types

1042
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@
}, },
"dependencies": { "dependencies": {
"d3": "^7.9.0", "d3": "^7.9.0",
"firebase": "^12.14.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,102 +1,37 @@
<script> <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 Layout from './lib/layout/Layout.svelte';
import WorldMap from './lib/world-map/WorldMap.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 StatsPanel from './lib/world-map/StatsPanel.svelte';
import TimelineView from './lib/timeline/view/TimelineView.svelte'; import TimelineView from './lib/timeline/TimelineView.svelte';
let screen = $state('worldmap'); let screen = $state('worldmap');
let journeyActive = $state(false);
let journeyProgress = $state(null);
let inDetail = $state(false); let inDetail = $state(false);
let pendingCountry = $state(''); let pendingCountry = $state('');
let journeyMode = $state('map');
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) { function handleCountryClick(name) {
pendingCountry = name; pendingCountry = name;
screen = 'timeline'; screen = 'timeline';
} }
$effect(() => {
initAuth();
});
let loading = $derived(getLoading());
let user = $derived(getUser());
let needsCountry = $derived(getNeedsCountry());
</script> </script>
{#if loading} <Layout {screen} onNavigate={(s) => (screen = s)} hideTopBar={inDetail}>
<div class="loading-screen"> {#if screen === 'worldmap'}
<span class="loading-text">Loading...</span> <div class="worldmap-page">
</div> <div class="map-area">
{:else} <WorldMap onCountryClick={handleCountryClick} />
<Layout {screen} {onNavigate} hideTopBar={inDetail}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area">
{#if journeyActive}
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} mode={journeyMode} onmodechange={(m) => journeyMode = m} />
{:else}
<WorldMap onCountryClick={handleCountryClick} />
<button class="journey-play-btn" onclick={startJourney}> Replay My Trips</button>
{/if}
</div>
{#if !journeyActive}<StatsPanel />{/if}
</div> </div>
{:else} <StatsPanel />
<TimelineView </div>
onDetailChange={(v) => (inDetail = v)} {:else}
{pendingCountry} <TimelineView
onNewEntryClear={() => (pendingCountry = '')} onDetailChange={(v) => (inDetail = v)}
onGoToMap={() => { screen = 'worldmap'; }} {pendingCountry}
/> onNewEntryClear={() => (pendingCountry = '')}
{/if} />
</Layout>
{#if !user}
<LoginOverlay />
{:else if needsCountry}
<CountryPicker />
{/if} {/if}
{/if} </Layout>
<style> <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 { .worldmap-page {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -109,37 +44,5 @@
.map-area { .map-area {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
position: relative;
} }
.journey-play-btn {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
padding: 12px 28px;
border-radius: 24px;
border: none;
background: #8b5cf6;
color: #fff;
font-size: 15px;
font-weight: 600;
gap: 6px;
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> </style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

1
src/assets/svelte.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,190 +0,0 @@
<script>
import { getUser, getUserProfile, setHomeCountry } from './userStore.svelte.js';
import { countryNames } from '../shared/countries.js';
import homeImg from '../../assets/home.png';
let user = $derived(getUser());
let profile = $derived(getUserProfile());
let search = $state('');
let selectedCountry = $state('');
let open = $state(false);
let filtered = $derived(
search.trim()
? countryNames.filter(c => c.toLowerCase().includes(search.toLowerCase()))
: countryNames
);
function select(name) {
selectedCountry = name;
search = name;
open = false;
}
function handleSubmit() {
if (selectedCountry) {
setHomeCountry(selectedCountry, selectedCountry);
}
}
function handleKeydown(e) {
if (e.key === 'Enter' && selectedCountry) handleSubmit();
if (e.key === 'Escape') open = false;
}
</script>
<div class="overlay">
<div class="card">
<img src={homeImg} alt="home" class="home-img" />
<h1 class="title">Welcome, {profile?.displayName?.split(' ')[0] || 'Traveler'}!</h1>
<p class="subtitle">Where do you call home?</p>
<div class="dropdown">
<input
type="text"
placeholder="Search country..."
bind:value={search}
onfocus={() => { open = true; }}
oninput={() => { open = true; selectedCountry = ''; }}
onkeydown={handleKeydown}
class="search-input"
/>
{#if open && filtered.length > 0}
<ul class="list" role="listbox">
{#each filtered as name}
<li
role="option"
aria-selected={selectedCountry === name}
class:selected={selectedCountry === name}
onmousedown={() => select(name)}
tabindex="0"
>{name}</li>
{/each}
</ul>
{/if}
</div>
<button class="continue-btn" disabled={!selectedCountry} onclick={handleSubmit}>
Set home country
</button>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding-bottom: 20vh;
}
.card {
text-align: center;
max-width: 360px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.home-img {
width: 200px;
height: 200px;
object-fit: contain;
margin-bottom: 16px;
}
.title {
font-family: var(--heading);
font-size: 24px;
font-weight: 600;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0 0 6px;
}
.subtitle {
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text);
margin: 0 0 24px;
}
.dropdown {
position: relative;
width: 100%;
margin-bottom: 16px;
text-align: left;
}
.search-input {
width: 100%;
padding: 10px 14px;
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: 300;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.search-input:focus { border-color: var(--accent-border); }
.search-input::placeholder { color: var(--text-sub); }
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
list-style: none;
z-index: 10;
padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.list li {
padding: 8px 12px;
cursor: pointer;
color: var(--text);
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
border-radius: 6px;
transition: background 0.1s;
}
.list li:hover, .list li.selected {
background: var(--accent-bg);
color: var(--accent);
}
.continue-btn {
width: 100%;
padding: 11px 24px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--accent);
color: #fff;
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.continue-btn:hover:not(:disabled) { background: var(--accent-dark); }
.continue-btn:disabled { opacity: 0.4; cursor: default; }
</style>

View File

@@ -1,114 +0,0 @@
<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;
padding-bottom: 20vh;
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;
animation: jitter 1.4s steps(1, end) 1 forwards;
}
@keyframes jitter {
0% { transform: scale(0.7) rotate(0deg); opacity: 0.5; }
8% { transform: scale(0.85) rotate(-16deg); opacity: 1; }
16% { transform: scale(1.0) rotate(16deg); }
24% { transform: scale(1.06) rotate(-16deg); }
32% { transform: scale(1.12) rotate(16deg); }
40% { transform: scale(1.16) rotate(-16deg); }
48% { transform: scale(1.2) rotate(16deg); }
56% { transform: scale(1.2) rotate(-16deg); }
64% { transform: scale(1.2) rotate(16deg); }
72% { transform: scale(1.2) rotate(-10deg); }
80% { transform: scale(1.2) rotate(10deg); }
88% { transform: scale(1.2) rotate(-4deg); }
94% { transform: scale(1.2) rotate(4deg); }
100% { transform: scale(1.2) rotate(0deg); }
}
.title {
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

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

View File

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

View File

@@ -1,235 +1,73 @@
<script> <script>
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
let { screen, onNavigate } = $props(); 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> </script>
<div class="topbar"> <nav class="topbar">
<div class="left"> <div class="logo-area">
<div class="brand"> <span class="logo">Journi</span>
<span class="app-name">Journi</span>
</div>
</div> </div>
<div class="nav-links">
<div class="center"> <button class="nav-btn" class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Map</button>
<div class="segmented"> <button class="nav-btn" class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button>
<div
class="slider"
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
></div>
<button class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Worldmap</button>
<button class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button>
</div>
</div> </div>
</nav>
<div class="right">
{#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> <style>
.topbar { .topbar {
height: 52px; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 32px; padding: 0 32px;
gap: 16px; height: 52px;
position: relative;
z-index: 10;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg); background: var(--bg);
flex-shrink: 0; flex-shrink: 0;
z-index: 10;
} }
.left { .logo-area {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
} }
.brand { .logo {
display: flex;
align-items: center;
gap: 4px;
}
.app-name {
font-family: var(--heading); font-family: var(--heading);
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
color: var(--text-h); color: var(--text-h);
white-space: nowrap;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.center { .nav-links {
flex: 1;
display: flex; display: flex;
justify-content: center;
}
.segmented {
position: relative;
display: flex;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 9999px;
padding: 4px;
}
.slider {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: var(--accent);
border-radius: 9999px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
transition: transform 0.25s ease;
pointer-events: none;
}
.segmented button {
position: relative;
z-index: 1;
flex: 1;
padding: 6px 24px;
border: none;
background: none;
cursor: pointer;
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text);
letter-spacing: 0.01em;
transition: color 0.2s ease;
}
.segmented button.active {
color: #fff;
}
.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: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
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; gap: 2px;
position: absolute;
left: 50%;
transform: translateX(-50%);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
} }
.menu-name { .nav-btn {
font: 600 14px/1.3 sans-serif; font-family: var(--sans);
color: var(--text-h); font-size: 13px;
} font-weight: 300;
padding: 4px 18px;
.menu-email { border-radius: 6px;
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; border: none;
background: none; background: none;
text-align: left; color: var(--text);
font: 400 14px/1.4 sans-serif;
color: #ef4444;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s, color 0.15s;
letter-spacing: 0.01em;
} }
.nav-btn:hover { color: var(--text-h); }
.menu-item:hover { .nav-btn.active {
background: var(--bg-subtle); background: #7c3aed;
} color: #fff;
box-shadow: 0 1px 4px rgba(124,58,237,0.25);
.backdrop {
position: fixed;
inset: 0;
z-index: 30;
border: none;
background: transparent;
cursor: default;
} }
</style> </style>

View File

@@ -1,18 +1,19 @@
import { journals } from '../stores/journalStore.js';
import { nameToId } from '../shared/countries.js';
let selected = $state(new Set()); let selected = $state(new Set());
let totalCountries = $state(0); let totalCountries = $state(0);
let flashing = $state(new Set());
journals.subscribe((entries) => { export function toggle(id) {
const ids = new Set(); const next = new Set(selected);
for (const e of entries) { if (next.has(id)) {
const id = nameToId[e.location?.country]; next.delete(id);
if (id) ids.add(id); } else {
next.add(id);
} }
selected = ids; selected = next;
}); }
export function clearAll() {
selected = new Set();
}
export function getSelected() { export function getSelected() {
return selected; return selected;
@@ -25,16 +26,3 @@ export function setTotalCount(n) {
export function getTotalCount() { export function getTotalCount() {
return totalCountries; return totalCountries;
} }
export function getFlashing() {
return flashing;
}
export function flashCountry(countryName) {
const id = nameToId[countryName];
if (!id) return;
flashing = new Set([...flashing, id]);
setTimeout(() => {
flashing = new Set([...flashing].filter(x => x !== id));
}, 1600);
}

View File

@@ -3,7 +3,7 @@
* Searchable combobox input. * Searchable combobox input.
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }} * @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 { id, value = $bindable(), options, placeholder = '', required = false, onselect } = $props();
let query = $state(value); let query = $state(value);
let open = $state(false); let open = $state(false);
@@ -39,11 +39,7 @@
} }
function onBlur() { function onBlur() {
setTimeout(() => { setTimeout(() => { open = false; focused = -1; }, 150);
open = false;
focused = -1;
if (onblurcommit && query.trim()) onblurcommit(query.trim());
}, 150);
} }
// Keep query in sync if value is changed externally // Keep query in sync if value is changed externally

View File

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

View File

@@ -1,84 +1,25 @@
import { feature } from 'topojson-client'; export const countryCodeMap = {
import worldData from 'world-atlas/countries-50m.json'; 'Argentina': 'AR', 'Australia': 'AU', 'Austria': 'AT',
'Belgium': 'BE', 'Brazil': 'BR',
// Full name → alpha-2 map covering all world-atlas country names. 'Canada': 'CA', 'Chile': 'CL', 'China': 'CN', 'Croatia': 'HR',
const nameToAlpha2 = { 'Czech Republic': 'CZ', 'Denmark': 'DK', 'Egypt': 'EG',
'Afghanistan':'AF','Albania':'AL','Algeria':'DZ','American Samoa':'AS', 'Finland': 'FI', 'France': 'FR', 'Germany': 'DE', 'Greece': 'GR',
'Andorra':'AD','Angola':'AO','Anguilla':'AI','Antigua and Barb.':'AG', 'Hungary': 'HU', 'India': 'IN', 'Indonesia': 'ID', 'Italy': 'IT',
'Argentina':'AR','Armenia':'AM','Aruba':'AW','Ashmore and Cartier Is.':'AU', 'Japan': 'JP', 'Kenya': 'KE',
'Australia':'AU','Austria':'AT','Azerbaijan':'AZ','Bahamas':'BS', 'Malaysia': 'MY', 'Mexico': 'MX', 'Morocco': 'MA',
'Bahrain':'BH','Bangladesh':'BD','Barbados':'BB','Belarus':'BY', 'Netherlands': 'NL', 'New Zealand': 'NZ', 'Norway': 'NO',
'Belgium':'BE','Belize':'BZ','Benin':'BJ','Bermuda':'BM','Bhutan':'BT', 'Peru': 'PE', 'Poland': 'PL', 'Portugal': 'PT',
'Bolivia':'BO','Bosnia and Herz.':'BA','Botswana':'BW', 'Singapore': 'SG', 'South Africa': 'ZA', 'South Korea': 'KR',
'Br. Indian Ocean Ter.':'IO','Brazil':'BR','British Virgin Is.':'VG', 'Spain': 'ES', 'Sweden': 'SE', 'Switzerland': 'CH',
'Brunei':'BN','Bulgaria':'BG','Burkina Faso':'BF','Burundi':'BI', 'Taiwan': 'TW', 'Thailand': 'TH', 'Turkey': 'TR',
'Cabo Verde':'CV','Cambodia':'KH','Cameroon':'CM','Canada':'CA', 'UK': 'GB', 'USA': 'US', 'Vietnam': 'VN',
'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 = Object.keys(countryCodeMap).sort();
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 */ /** @param {string} country */
export function flagEmoji(country) { export function flagEmoji(country) {
const code = nameToAlpha2[country]; const code = countryCodeMap[country];
if (!code) return ''; if (!code) return '';
return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join(''); return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join('');
} }

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,123 @@
export { journals } from './entriesStore.svelte.js'; import { writable } from 'svelte/store';
export { addEntry as addJournal } from './entriesStore.svelte.js';
/**
* @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
*/
/** @type {JournalEntry[]} */
const mockEntries = [
{
id: '1',
title: 'First Day in Tokyo',
date: '2024-03-15',
location: { country: 'Japan', cities: ['Tokyo'] },
photos: [
'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=600&q=80',
'https://images.unsplash.com/photo-1513407030348-c983a97b98d8?w=600&q=80',
'https://images.unsplash.com/photo-1490806843957-31f4c9a91c65?w=600&q=80',
],
transport: 'flight',
tripType: 'solo',
days: 5,
memo: 'Got completely lost in Shinjuku — stumbled into a tiny ramen shop with no English menu. The chashu just melted. Worth every wrong turn.',
},
{
id: '2',
title: 'Arashiyama Bamboo Grove',
date: '2024-03-18',
location: { country: 'Japan', cities: ['Kyoto'] },
photos: [
'https://images.unsplash.com/photo-1528360983277-13d401cdc186?w=600&q=80',
'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=600&q=80',
],
transport: 'train',
tripType: 'friends',
days: 3,
memo: 'Arrived at 6am before the crowds. Just me and the wind moving through the bamboo. One of those moments you keep coming back to.',
},
{
id: '3',
title: 'Sunset on Montmartre',
date: '2024-06-02',
location: { country: 'France', cities: ['Paris'] },
photos: [
'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=600&q=80',
'https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=600&q=80',
'https://images.unsplash.com/photo-1511739001486-6bfe10ce785f?w=600&q=80',
],
transport: 'flight',
tripType: 'solo',
days: 7,
memo: 'Watched the whole city turn orange from the steps of Sacré-Cœur. A street musician was playing La Vie en Rose. Cliché, perfect.',
},
{
id: '4',
title: 'Inside La Sagrada Família',
date: '2024-06-10',
location: { country: 'Spain', cities: ['Barcelona'] },
photos: [
'https://images.unsplash.com/photo-1523531294919-4bcd7c65e216?w=600&q=80',
'https://images.unsplash.com/photo-1583422409516-2895a77efded?w=600&q=80',
],
transport: 'flight',
tripType: 'friends',
days: 4,
memo: 'Nothing prepares you for the light inside. The stained glass turns the whole nave into a kaleidoscope. Gaudí was building a forest.',
},
{
id: '5',
title: 'Central Park in Fall',
date: '2023-10-20',
location: { country: 'USA', cities: ['New York'] },
photos: [
'https://images.unsplash.com/photo-1534430480872-3498386e7856?w=600&q=80',
'https://images.unsplash.com/photo-1485871981521-5b1fd3805345?w=600&q=80',
'https://images.unsplash.com/photo-1522083165195-3424ed129620?w=600&q=80',
],
transport: 'car',
tripType: 'friends',
days: 6,
memo: 'Peak foliage. Joggers, picnics, a guy playing saxophone near Bethesda Fountain. Hard to believe a city this big wraps around this much quiet.',
},
{
id: '6',
title: 'Wat Pho Reclining Buddha',
date: '2024-01-08',
location: { country: 'Thailand', cities: ['Bangkok'] },
photos: [
'https://images.unsplash.com/photo-1563492065599-3520f775eeed?w=600&q=80',
'https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=600&q=80',
],
transport: 'ship',
tripType: 'solo',
days: 2,
memo: 'Stood in front of the 45m golden Buddha for a long time. The mother-of-pearl inlay on the soles of the feet is impossibly detailed.',
},
];
export const journals = writable(mockEntries);
/** @param {Omit<JournalEntry, 'id'>} entry */
export function addJournal(entry) {
journals.update((entries) => [...entries, { ...entry, id: crypto.randomUUID() }]);
}
/** @param {string} id */
export function removeJournal(id) {
journals.update((entries) => entries.filter((e) => e.id !== id));
}
/** @param {JournalEntry} updated */
export function updateJournal(updated) {
journals.update((entries) => entries.map((e) => e.id === updated.id ? updated : e));
}

View File

@@ -4,7 +4,7 @@
<div class="overlay" role="dialog" aria-modal="true"> <div class="overlay" role="dialog" aria-modal="true">
<div class="dialog"> <div class="dialog">
<h2 class="title">Delete trip?</h2> <h2 class="title">Delete entry?</h2>
<p class="body"> <p class="body">
<strong>{entry.location.cities.join(', ')}, {entry.location.country}</strong>{entry.date.slice(0, 4)} will be permanently removed. <strong>{entry.location.cities.join(', ')}, {entry.location.country}</strong>{entry.date.slice(0, 4)} will be permanently removed.
</p> </p>

View File

@@ -1,15 +1,14 @@
<script> <script>
import { getEntries } from '../../stores/entriesStore.svelte.js'; import { get } from 'svelte/store';
import { addEntry, updateEntry } from '../../stores/entriesStore.svelte.js'; import { journals, addJournal, updateJournal } from '../stores/journalStore.js';
import { countryNames } from '../../shared/countries.js'; import { countryNames } from '../shared/countries.js';
import { getCitiesForCountry, ALL_CITIES } from '../../shared/cities.js'; import SearchInput from '../shared/SearchInput.svelte';
import SearchInput from '../../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte'; import PhotoEditor from './PhotoEditor.svelte';
/** /**
* entry = null → "new entry" mode * entry = null → "new entry" mode
* entry = {...} → "edit" mode * entry = {...} → "edit" mode
* @type {{ entry?: import('../shared/types.js').JournalEntry | null, initialCountry?: string, onBack: () => void }} * @type {{ entry?: import('../stores/journalStore.js').JournalEntry | null, initialCountry?: string, onBack: () => void }}
*/ */
let { entry = null, initialCountry = '', onBack } = $props(); let { entry = null, initialCountry = '', onBack } = $props();
@@ -19,19 +18,11 @@
let cityInput = $state(''); let cityInput = $state('');
let country = $state(entry?.location.country ?? initialCountry); let country = $state(entry?.location.country ?? initialCountry);
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10)); let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
let days = $state(String(entry?.days ?? '')); let days = $state(String(entry?.days ?? 1));
let tripType = $state(entry?.tripType ?? ''); let tripType = $state(entry?.tripType ?? 'solo');
let photos = $state([...(entry?.photos ?? [])]); let photos = $state([...(entry?.photos ?? [])]);
let memo = $state(entry?.memo ?? ''); let memo = $state(entry?.memo ?? '');
let transport = $state(entry?.transport ?? ''); let transport = $state(entry?.transport ?? 'flight');
let errors = $state({
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
});
function clearErrors() {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
}
const transportOptions = [ const transportOptions = [
{ value: 'flight', label: '✈ Flight' }, { value: 'flight', label: '✈ Flight' },
@@ -58,12 +49,8 @@
} }
} }
// Suggest cities — when a country is selected show only cities from that country.
let allEntries = $derived(getEntries());
let cityOptions = $derived( let cityOptions = $derived(
country.trim() [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
); );
function addCity(val) { function addCity(val) {
@@ -78,44 +65,31 @@
cities = cities.filter(x => x !== c); cities = cities.filter(x => x !== c);
} }
async function save() { function save() {
clearErrors(); if (isNew) {
let hasError = false; addJournal({
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; } title: `${cities.join(', ')}, ${country}`,
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; } date,
if (!date) { errors.date = 'Date is required.'; hasError = true; } days: Number(days),
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; } tripType,
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; } memo,
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; } photos,
if (hasError) return; transport,
location: { cities, country },
try { });
if (isNew) { } else {
await addEntry({ updateJournal({
title: `${cities.join(', ')}, ${country}`, ...entry,
date, date,
days: Number(days), days: Number(days),
tripType, tripType,
memo, transport,
photos, memo,
transport, photos,
location: { cities, country }, location: { cities, country },
}); });
} else {
await updateEntry(entry.id, {
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
}
onBack();
} catch (err) {
console.error('Save failed:', err);
} }
onBack();
} }
</script> </script>
@@ -130,7 +104,7 @@
Back Back
</button> </button>
</div> </div>
<span class="topbar-title">{isNew ? 'New trip' : 'Edit'}</span> <span class="topbar-title">{isNew ? 'New entry' : 'Edit'}</span>
<div class="topbar-right"> <div class="topbar-right">
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button> <button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
</div> </div>
@@ -143,14 +117,12 @@
<div class="field"> <div class="field">
<label class="label" for="edit-country">Country <span class="req">*</span></label> <label class="label" for="edit-country">Country <span class="req">*</span></label>
<SearchInput id="edit-country" bind:value={country} options={countryNames} required /> <SearchInput id="edit-country" bind:value={country} options={countryNames} required />
{#if errors.country}<span class="field-error">{errors.country}</span>{/if}
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="edit-city">Cities <span class="req">*</span></label> <label class="label" for="edit-city">Cities <span class="req">*</span></label>
<div class="city-input-row"> <div class="city-input-row">
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} /> <SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
</div> </div>
{#if errors.cities}<span class="field-error">{errors.cities}</span>{/if}
{#if cities.length > 0} {#if cities.length > 0}
<div class="city-tags"> <div class="city-tags">
{#each cities as c} {#each cities as c}
@@ -168,12 +140,10 @@
<div class="field"> <div class="field">
<label class="label" for="edit-date">Date <span class="req">*</span></label> <label class="label" for="edit-date">Date <span class="req">*</span></label>
<input id="edit-date" class="input" type="date" bind:value={date} required /> <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>
<div class="field"> <div class="field">
<label class="label" for="edit-days">Days <span class="req">*</span></label> <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 /> <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> </div>
@@ -190,7 +160,6 @@
<input type="radio" name="tripType" value="family" bind:group={tripType} /> With family <input type="radio" name="tripType" value="family" bind:group={tripType} /> With family
</label> </label>
</div> </div>
{#if errors.tripType}<span class="field-error">{errors.tripType}</span>{/if}
</div> </div>
<div class="field"> <div class="field">
@@ -203,7 +172,6 @@
</label> </label>
{/each} {/each}
</div> </div>
{#if errors.transport}<span class="field-error">{errors.transport}</span>{/if}
</div> </div>
<PhotoEditor {photos} onchange={(p) => (photos = p)} /> <PhotoEditor {photos} onchange={(p) => (photos = p)} />
@@ -222,12 +190,6 @@
</div> </div>
<style> <style>
.field-error {
font-size: 11px;
color: #dc2626;
margin-top: 2px;
}
.edit-layout { .edit-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -241,7 +203,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 20px;
height: 60px; height: 52px;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg); background: var(--bg);
@@ -256,8 +218,8 @@
.topbar-right { justify-content: flex-end; } .topbar-right { justify-content: flex-end; }
.topbar-title { .topbar-title {
font-size: 16px; font-size: 14px;
font-weight: 500; font-weight: 400;
color: var(--text-h); color: var(--text-h);
} }
@@ -266,13 +228,13 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-family: var(--sans); font-family: var(--sans);
font-size: 15px; font-size: 13px;
font-weight: 400; font-weight: 300;
color: var(--text); color: var(--text);
background: none; background: none;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 10px; border-radius: 8px;
padding: 8px 14px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s; transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap; white-space: nowrap;

View File

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

View File

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

View File

@@ -1,35 +1,32 @@
<script> <script>
import { storage } from '../../firebase.js';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */ /** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
let { photos, onchange } = $props(); let { photos, onchange } = $props();
let fileInput; let fileInput;
let uploading = $state(false);
function remove(index) { function remove(index) {
onchange(photos.filter((_, i) => i !== index)); const next = photos.filter((_, i) => i !== index);
onchange(next);
} }
async function addFiles(e) { async function addFiles(e) {
const files = Array.from(e.currentTarget.files ?? []); const files = Array.from(e.currentTarget.files ?? []);
if (!files.length) return; if (!files.length) return;
uploading = true;
try { const dataUrls = await Promise.all(files.map(fileToDataUrl));
const urls = await Promise.all(files.map(uploadPhoto)); onchange([...photos, ...dataUrls]);
onchange([...photos, ...urls]);
} finally { // reset so the same file can be picked again
uploading = false; e.currentTarget.value = '';
e.currentTarget.value = '';
}
} }
/** @param {File} file */ /** @param {File} file */
async function uploadPhoto(file) { function fileToDataUrl(file) {
const storageRef = ref(storage, `photos/${crypto.randomUUID()}`); return new Promise((resolve) => {
await uploadBytes(storageRef, file); const reader = new FileReader();
return getDownloadURL(storageRef); reader.onload = (e) => resolve(/** @type {string} */ (e.target.result));
reader.readAsDataURL(file);
});
} }
</script> </script>
@@ -40,13 +37,13 @@
</div> </div>
{#if photos.length === 0} {#if photos.length === 0}
<button type="button" class="empty-zone" onclick={() => fileInput.click()} disabled={uploading}> <button type="button" class="empty-zone" onclick={() => fileInput.click()}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"> <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"/> <rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8.5" cy="8.5" r="1.5"/> <circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/> <path d="M21 15l-5-5L5 21"/>
</svg> </svg>
<span>{uploading ? 'Uploading…' : 'Click to add photos'}</span> <span>Click to add photos</span>
</button> </button>
{:else} {:else}
<div class="grid"> <div class="grid">
@@ -61,7 +58,7 @@
</div> </div>
{/each} {/each}
<button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}> <button type="button" class="add-cell" onclick={() => fileInput.click()}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <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"/> <path d="M12 5v14M5 12h14"/>
</svg> </svg>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClose: () => void }} */ /** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClose: () => void }} */
let { entries, onClose } = $props(); let { entries, onClose } = $props();
let cardEl = $state(null); let cardEl = $state(null);

View File

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

View File

@@ -1,7 +1,7 @@
<script> <script>
import { flagEmoji } from '../../shared/countries.js'; import { flagEmoji } from '../shared/countries.js';
/** @type {{ entry: import('../shared/types.js').JournalEntry, onClick: () => void }} */ /** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onClick: () => void }} */
let { entry, onClick } = $props(); let { entry, onClick } = $props();
function formatDate(/** @type {string} */ iso) { function formatDate(/** @type {string} */ iso) {

View File

@@ -1,14 +1,14 @@
<script> <script>
import { getEntries } from '../../stores/entriesStore.svelte.js'; import { get } from 'svelte/store';
import { journals } from '../stores/journalStore.js';
import TimelineToolbar from './TimelineToolbar.svelte'; import TimelineToolbar from './TimelineToolbar.svelte';
import TimelineCard from './TimelineCard.svelte'; import TimelineCard from './TimelineCard.svelte';
import JournalDetail from '../detail/JournalDetail.svelte'; import JournalDetail from './JournalDetail.svelte';
import EditForm from '../detail/EditForm.svelte'; import EditForm from './EditForm.svelte';
import NewEntryForm from '../detail/NewEntryForm.svelte';
import ShareCard from './ShareCard.svelte'; import ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte'; import SharePreview from './SharePreview.svelte';
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {}, onGoToMap = () => {} } = $props(); let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null)); let selectedId = $state(/** @type {string|null} */(null));
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list')); let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
let showShare = $state(false); let showShare = $state(false);
@@ -25,7 +25,11 @@
}); });
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null); let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
let entries = $derived(getEntries()); let entries = $state(get(journals));
$effect(() => {
const unsub = journals.subscribe((v) => { entries = v; });
return unsub;
});
let sortKey = $state('date-desc'); let sortKey = $state('date-desc');
@@ -50,7 +54,7 @@
{#if view === 'new'} {#if view === 'new'}
<div class="detail-scroll"> <div class="detail-scroll">
<NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} onSaved={() => { onGoToMap(); }} /> <EditForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} />
</div> </div>
{:else if view === 'edit' && selected} {:else if view === 'edit' && selected}
<div class="detail-scroll"> <div class="detail-scroll">
@@ -65,19 +69,20 @@
/> />
</div> </div>
{:else} {:else}
<div class="list-view"> <div class="right-panel">
<div class="page-header">
<h1 class="page-title">My Journey</h1>
<button class="new-btn" onclick={() => { view = 'new'; }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>
Add trip
</button>
</div>
<div class="two-col"> <div class="two-col">
<div class="left-col"> <!-- Timeline column -->
<div class="timeline-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>
New entry
</button>
</div>
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} /> <TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0} {#if sortedEntries.length === 0}
@@ -105,12 +110,13 @@
{/if} {/if}
<footer class="page-footer"> <footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'} {sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
</footer> </footer>
</div> </div>
<!-- Share preview column -->
{#if sortedEntries.length > 0} {#if sortedEntries.length > 0}
<div class="right-col"> <div class="share-col">
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} /> <SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
</div> </div>
{/if} {/if}
@@ -134,51 +140,38 @@
overflow: hidden; overflow: hidden;
} }
/* ── List view wrapper (scrollable) ── */ /* ── Right panel ── */
.list-view { .right-panel {
flex: 1; flex: 1;
min-width: 0;
overflow-y: auto; overflow-y: auto;
padding: 48px 0 80px; background: var(--bg);
}
/* ── Two-column layout ── */
.two-col {
display: grid;
grid-template-columns: 1fr 240px;
gap: 48px;
max-width: 1020px;
width: 100%;
margin: 0 auto;
padding: 48px 48px 80px;
align-items: start;
box-sizing: border-box; box-sizing: border-box;
min-width: 0;
} }
.page-header, .timeline-col { min-width: 0; }
.two-col {
max-width: 960px;
margin-left: auto;
margin-right: auto;
padding-left: 48px;
padding-right: 48px;
}
/* ── Two-column below header ── */ .share-col { padding-top: 60px; }
.two-col {
display: flex;
flex-direction: row;
gap: 32px;
align-items: flex-start;
margin-top: 24px;
}
.left-col { /* ── Responsive: narrow viewport ── */
flex: 1; @media (max-width: 860px) {
min-width: 0; .two-col {
} grid-template-columns: 1fr;
padding: 32px 24px 60px;
.right-col { }
width: 260px; .share-col { padding-top: 0; }
flex-shrink: 0;
position: sticky;
top: 0;
}
@media (max-width: 900px) {
.right-col { display: none; }
}
@media (max-width: 760px) {
.list-view { padding: 32px 24px 60px; }
} }
/* ── Detail view ── */ /* ── Detail view ── */
@@ -292,4 +285,38 @@
} }
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); } .new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.share-nudge {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 10px;
border: 1px dashed var(--border-bright);
background: var(--bg-subtle);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: var(--sans);
text-align: left;
}
.share-nudge:hover {
border-color: var(--accent-light);
background: var(--accent-bg);
}
.nudge-left {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 400;
color: var(--text-h);
}
.nudge-right {
font-size: 11px;
font-weight: 300;
color: var(--text-sub);
letter-spacing: 0.02em;
}
.share-nudge:hover .nudge-right { color: var(--accent); }
</style> </style>

View File

@@ -1,502 +0,0 @@
<script>
import { journals, addJournal } from '../../stores/journalStore.js';
import { get } from 'svelte/store';
import { flashCountry } from '../../layout/selection.svelte.js';
import { countryNames } from '../../shared/countries.js';
import { countryCities } from '../../shared/countryCities.js';
import SearchInput from '../../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
import airplaneImg from '../../../assets/airplane.png';
import trainImg from '../../../assets/train.png';
import busImg from '../../../assets/bus.png';
import carImg from '../../../assets/car.png';
import shipImg from '../../../assets/ship.png';
import walkImg from '../../../assets/walk.png';
let { initialCountry = '', onBack, onSaved = onBack } = $props();
// ── Journal store (reactive) ────────────────────────────────────────
let journalEntries = $state(get(journals));
$effect(() => {
const unsub = journals.subscribe(v => { journalEntries = v; });
return unsub;
});
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
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([
...(countryCities[country.trim()] ?? []),
...journalEntries.filter(j => (j.location?.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location?.cities ?? []),
])]
: []
);
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);
let saveError = $state('');
async function save() {
saving = true;
saveError = '';
const memo = questions
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
.filter(Boolean)
.join('\n\n');
try {
await addJournal({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
flashCountry(country);
onSaved();
} catch (e) {
saving = false;
saveError = e?.message ?? 'Failed to save. Please try again.';
}
}
</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 saveError}<span class="save-err">{saveError}</span>{/if}
{/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">Where did you go? <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">Which cities did you visit? <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">When did you travel? <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">How long was the trip? <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">Who did you travel with? <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; }
.save-err { font-size: 12px; color: #ef4444; margin-top: 4px; display: block; text-align: right; }
/* 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

@@ -1,427 +0,0 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
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, mode = 'map', onmodechange } = $props();
const HOME_CODE = '203';
const TRANSPORT_IMG = {
flight: airplaneImg,
train: trainImg,
bus: busImg,
car: carImg,
ship: shipImg,
walk: walkImg,
};
const PLANE_SIZE = 28;
const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e';
const ARC_COLOR = '#666666';
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, gBase, gCountries, gAnim, pathFn, projection;
let countryPaths;
let homeFeature;
let featuresById = {};
let countriesData = [];
let isCancelled = false;
let isPlaying = $state(false);
let isFinished = $state(false);
let visitedCodes = new Set();
let animId = 0;
let currentDateLabel = $state('');
function formatDateLabel(dateStr) {
const d = new Date(dateStr);
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return `${months[d.getMonth()]} ${d.getFullYear()}`;
}
function computeArc(p1, p2) {
const interp = d3.geoInterpolate(p1, p2);
const raw = [];
for (let i = 0; i <= 80; i++) {
const t = i / 80;
const pt = projection(interp(t));
if (!pt) continue;
raw.push({ t, x: pt[0], y: pt[1] });
}
if (raw.length < 2) return [];
const first = raw[0], last = raw[raw.length - 1];
const dist = Math.sqrt((last.x-first.x)**2 + (last.y-first.y)**2);
const arcH = Math.max(40, Math.min(200, dist * 0.22));
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
}
function planeTransform(x, y, angle, flip) {
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
}
function delay(ms) {
return new Promise(resolve => { if (isCancelled) { resolve(); return; } setTimeout(resolve, ms); });
}
function setupProjection(width, height) {
if (mode === 'map') {
projection = d3.geoMercator();
projection.fitSize([width, height], { type: 'Sphere' });
const s = projection.scale() * 1.5;
projection.scale(s).translate([width / 2, height * 0.70]);
} else {
const size = Math.min(width, height) * 0.92;
projection = d3.geoOrthographic()
.rotate([0, 0])
.fitSize([size, size], { type: 'Sphere' })
.translate([width / 2, height / 2]);
}
pathFn = d3.geoPath().projection(projection);
}
function renderMap() {
gBase.selectAll('*').remove();
gCountries.selectAll('*').remove();
gAnim.selectAll('*').remove();
const fillFn = d => {
const id = effId(d);
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
if (visitedCodes.has(id)) return VISITED_COLOR;
return UNVISITED;
};
if (mode === 'globe') {
gBase.append('path').attr('class', 'sphere').datum({ type: 'Sphere' })
.attr('d', pathFn).attr('fill', '#a4c8e0').attr('stroke', '#8b9bb0').attr('stroke-width', 1.5);
}
countryPaths = gCountries.selectAll('path')
.data(countriesData, d => effId(d)).join('path')
.attr('d', pathFn).attr('fill', fillFn)
.attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4')
.attr('stroke-width', mode === 'globe' ? 0.3 : 0.5);
if (mode === 'map') renderMicrostates();
}
function renderMicrostates() {
gBase.selectAll('.micro-state-j').remove();
const fillFn = d => {
const id = effId(d);
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
if (visitedCodes.has(id)) return VISITED_COLOR;
return UNVISITED;
};
countryPaths.each(function(d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < 4 && height < 4) {
const [cx, cy] = pathFn.centroid(d);
gBase.append('circle').attr('class', 'micro-state-j').datum(d)
.attr('cx', cx).attr('cy', cy).attr('r', 2).attr('fill', fillFn(d))
.attr('stroke', '#94a3b8').attr('stroke-width', 0.5);
}
});
}
function redrawBase() {
countryPaths.attr('d', pathFn);
if (mode === 'globe') gBase.select('.sphere').attr('d', pathFn);
}
function rotateGlobeTo(lon, lat, duration = 1500) {
return new Promise(resolve => {
if (isCancelled) { resolve(); return; }
const current = projection.rotate();
const interp = d3.geoInterpolate([-current[0], -current[1]], [lon, lat]);
const timer = d3.timer(elapsed => {
if (isCancelled) { timer.stop(); resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const point = interp(t);
projection.rotate([-point[0], -point[1]]);
redrawBase();
if (t >= 1) { timer.stop(); resolve(); return true; }
});
});
}
function createArcEl(iconSrc) {
const el = gAnim.append('path')
.attr('fill', 'none').attr('stroke', ARC_COLOR)
.attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
.attr('stroke-linecap', 'round').attr('stroke-dasharray', '10, 6');
const tip = gAnim.append('image')
.attr('href', iconSrc).attr('width', PLANE_SIZE).attr('height', PLANE_SIZE)
.attr('x', -PLANE_SIZE / 2).attr('y', -PLANE_SIZE / 2)
.attr('preserveAspectRatio', 'xMidYMid meet').attr('opacity', 0);
return { el, tip };
}
function animateIncrementalPath(el, tip, pts, duration, flip = false) {
return new Promise(resolve => {
const lineGen = d3.line().curve(d3.curveBasis);
d3.timer(elapsed => {
if (isCancelled) { resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const count = Math.max(2, Math.floor(t * (pts.length - 1)) + 1);
const visible = pts.slice(0, count);
if (visible.length >= 2) el.attr('d', lineGen(visible));
if (visible.length > 0) {
const last = visible[visible.length - 1];
let angle = 0;
if (visible.length >= 2) {
const prev = visible[visible.length - 2];
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
}
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
}
if (t >= 1) { resolve(); return true; }
});
});
}
function animateReprojectingArc(el, tip, geoPts, lineGen, duration, flip = false) {
return new Promise(resolve => {
const timer = d3.timer(elapsed => {
if (isCancelled) { timer.stop(); resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const count = Math.max(2, Math.floor(t * (geoPts.length - 1)) + 1);
const screenPts = geoPts.slice(0, count).map(p => projection(p)).filter(Boolean);
if (screenPts.length >= 2) el.attr('d', lineGen(screenPts));
if (screenPts.length > 0) {
const last = screenPts[screenPts.length - 1];
let angle = 0;
if (screenPts.length >= 2) {
const prev = screenPts[screenPts.length - 2];
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
}
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
}
if (t >= 1) { timer.stop(); resolve(); return true; }
});
});
}
async function animateTrip(destCode, destFeature, transport = 'flight') {
if (!homeFeature || !destFeature) return;
const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg;
const homeCentroid = d3.geoCentroid(homeFeature);
const destCentroid = d3.geoCentroid(destFeature);
if (mode === 'map') {
await animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc);
} else {
await animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc);
}
}
async function animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc) {
const pts = computeArc(homeCentroid, destCentroid);
if (pts.length < 2) return;
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
await animateIncrementalPath(outEl, outTip, pts, 2500, pts[pts.length-1][0] < pts[0][0]);
if (isCancelled) return;
outEl.remove(); outTip.remove();
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
visitedCodes.add(destCode);
gBase.selectAll('.micro-state-j').filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
await delay(800);
if (isCancelled) return;
const revPts = [...pts].reverse();
const { el: retEl, tip: retTip } = createArcEl(iconSrc);
await animateIncrementalPath(retEl, retTip, revPts, 2200, revPts[revPts.length-1][0] < revPts[0][0]);
if (isCancelled) return;
retEl.remove(); retTip.remove();
await delay(300);
}
async function animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc) {
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
const geoPts = Array.from({ length: 81 }, (_, i) => interp(i / 80));
const dur = Math.round(1500 + d3.geoDistance(homeCentroid, destCentroid) * 2500);
const lineGen = d3.line().curve(d3.curveBasis);
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
const outGcs = geoPts.map(p => projection(p)).filter(Boolean);
await Promise.all([
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outGcs.length >= 2 && outGcs[outGcs.length-1][0] < outGcs[0][0]),
]);
if (isCancelled) return;
outEl.remove(); outTip.remove();
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
visitedCodes.add(destCode);
await delay(600);
if (isCancelled) return;
const revGeoPts = [...geoPts].reverse();
const { el: retEl, tip: retTip } = createArcEl(iconSrc);
const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean);
await Promise.all([
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retGcs.length >= 2 && retGcs[retGcs.length-1][0] < retGcs[0][0]),
]);
if (isCancelled) return;
retEl.remove(); retTip.remove();
await delay(300);
}
async function startJourney() {
const myId = ++animId;
isPlaying = true; isFinished = false; isCancelled = false; visitedCodes = new Set();
const width = frameEl.clientWidth, height = frameEl.clientHeight;
svg.selectAll('*').remove();
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
setupProjection(width, height);
if (mode === 'globe' && homeFeature) {
const c = d3.geoCentroid(homeFeature);
projection.rotate([-c[0], -c[1]]);
pathFn = d3.geoPath().projection(projection);
}
renderMap();
const nameToId = Object.fromEntries(Object.entries(featuresById).filter(([,f]) => f.properties?.name).map(([id, f]) => [f.properties.name, id]));
const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
const trips = entries.length > 0
? entries.map(e => ({
countryName: e.location.country,
countryCode: nameToId[e.location.country] ?? null,
city: e.location.cities?.[0] ?? e.location.country,
transport: e.transport ?? 'flight',
date: e.date,
})).filter(t => t.countryCode)
: [
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' },
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' },
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' },
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' },
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' },
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' },
];
for (let i = 0; i < trips.length; i++) {
if (isCancelled || myId !== animId) break;
const trip = trips[i];
if (trip.date) currentDateLabel = formatDateLabel(trip.date);
const destFeature = featuresById[trip.countryCode];
if (!destFeature) continue;
if (onprogress) onprogress({ index: i + 1, total: trips.length, label: `${trip.city}, ${trip.countryName}` });
await animateTrip(trip.countryCode, destFeature, trip.transport);
}
if (!isCancelled && myId === animId) {
isFinished = true; isPlaying = false;
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
} else if (myId === animId) { isPlaying = false; }
}
function stopJourney() { isCancelled = true; isPlaying = false; }
function replay() { stopJourney(); setTimeout(() => startJourney(), 100); }
function switchMode(target) {
if (target === mode) return;
onmodechange?.(target);
stopJourney();
setTimeout(() => startJourney(), 100);
}
function close() { stopJourney(); onclose?.(); }
onMount(() => {
const width = frameEl.clientWidth, height = frameEl.clientHeight;
setupProjection(width, height);
countriesData = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countriesData.forEach(f => { if (!f.id) f.id = 'XK'; });
for (const f of countriesData) featuresById[effId(f)] = f;
homeFeature = featuresById[HOME_CODE];
svg = d3.select(frameEl).append('svg').attr('width', width).attr('height', height).style('cursor', 'default');
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
renderMap();
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
const prevRotate = mode === 'globe' ? projection.rotate() : null;
if (mode === 'map') {
projection = d3.geoMercator();
projection.fitSize([width, height], { type: 'Sphere' });
projection.scale(projection.scale() * 1.5).translate([width / 2, height * 0.70]);
} else {
const size = Math.min(width, height) * 0.92;
projection = d3.geoOrthographic().rotate(prevRotate).fitSize([size, size], { type: 'Sphere' }).translate([width / 2, height / 2]);
}
pathFn = d3.geoPath().projection(projection);
redrawBase();
if (mode === 'map') renderMicrostates();
}
});
observer.observe(frameEl);
startJourney();
return () => { stopJourney(); observer.disconnect(); if (svg) svg.remove(); };
});
</script>
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
<div class="top-label">
{#if isFinished}Journey complete!{:else if currentDateLabel}{currentDateLabel}{/if}
</div>
<div class="control-bar">
<button class="control-btn" onclick={replay}> Replay</button>
<button class="control-btn" onclick={() => switchMode(mode === 'map' ? 'globe' : 'map')}>
{mode === 'map' ? '🌍 Globe view' : '🗺 Map view'}
</button>
<button class="control-btn" onclick={close}> Close</button>
</div>
</div>
<style>
.journey-frame { width: 100%; height: 100%; overflow: hidden; position: relative; background: #a4c8e0; }
.journey-frame.globe-mode { background: #ffffff; }
.journey-frame :global(svg) { display: block; }
.top-label {
position: absolute; top: 16px; left: 16px; z-index: 10;
background: rgba(0,0,0,0.65); color: #fff;
font-family: var(--heading, sans-serif); font-size: 16px; font-weight: 600;
padding: 10px 24px; border-radius: 24px; white-space: nowrap;
letter-spacing: 0.04em; min-width: 200px; text-align: center;
}
.control-bar {
position: absolute; bottom: 24px; right: 24px; z-index: 10;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
}
.control-btn {
padding: 10px 24px; border: none; border-radius: 24px;
background: #8b5cf6; color: #fff; font-size: 14px; font-weight: 600;
cursor: pointer; transition: background 0.15s ease; white-space: nowrap; font-family: inherit;
}
.control-btn:hover { background: #7c3aed; }
.control-btn:active { transform: scale(0.96); }
</style>

View File

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

View File

@@ -3,107 +3,26 @@
import * as d3 from 'd3'; import * as d3 from 'd3';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json'; import worldData from 'world-atlas/countries-50m.json';
import { getSelected, setTotalCount, getFlashing } from '../layout/selection.svelte.js'; import { getSelected, toggle, setTotalCount } from '../layout/selection.svelte.js';
import { getUserProfile } from '../auth/userStore.svelte.js';
import homeIconUrl from '../../assets/home.png';
import crayonCursorUrl from '../../assets/logo-cursor.png';
let { onCountryClick = (_name) => {} } = $props(); let { onCountryClick = (_name) => {} } = $props();
const TERRITORY_PARENT = { const TERRITORY_PARENT = {
'016': '840', // American Samoa -> United States '016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
'060': '826', // Bermuda -> United Kingdom '184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
'086': '826', // Br. Indian Ocean Ter. -> United Kingdom '258': '250', '260': '250', '304': '208', '316': '840', '334': '036',
'092': '826', // British Virgin Is. -> United Kingdom '446': '156', '500': '826', '531': '528', '533': '528', '534': '528',
'136': '826', // Cayman Is. -> United Kingdom '540': '250', '570': '554', '574': '036', '580': '840', '612': '826',
'184': '554', // Cook Is. -> New Zealand '630': '840', '652': '250', '654': '826', '660': '826', '663': '250',
'234': '208', // Faeroe Is. -> Denmark '666': '250', '796': '826', '831': '826', '832': '826', '833': '826',
'238': '826', // Falkland Is. -> United Kingdom '850': '840', '876': '250',
'239': '826', // S. Geo. and the Is. -> United Kingdom
'248': '246', // Aland -> Finland
'258': '250', // Fr. Polynesia -> France
'260': '250', // Fr. S. Antarctic Lands -> France
'304': '208', // Greenland -> Denmark
'316': '840', // Guam -> United States
'334': '036', // Heard I. and McDonald Is. -> Australia
'446': '156', // Macao -> China
'500': '826', // Montserrat -> United Kingdom
'531': '528', // Curacao -> Netherlands
'533': '528', // Aruba -> Netherlands
'534': '528', // Sint Maarten -> Netherlands
'540': '250', // New Caledonia -> France
'570': '554', // Niue -> New Zealand
'574': '036', // Norfolk Island -> Australia
'580': '840', // N. Mariana Is. -> United States
'612': '826', // Pitcairn Is. -> United Kingdom
'630': '840', // Puerto Rico -> United States
'652': '250', // St-Barthelemy -> France
'654': '826', // Saint Helena -> United Kingdom
'660': '826', // Anguilla -> United Kingdom
'663': '250', // St-Martin -> France
'666': '250', // St. Pierre and Miquelon -> France
'796': '826', // Turks and Caicos Is. -> United Kingdom
'831': '826', // Guernsey -> United Kingdom
'832': '826', // Jersey -> United Kingdom
'833': '826', // Isle of Man -> United Kingdom
'850': '840', // U.S. Virgin Is. -> United States
'876': '250', // Wallis and Futuna Is. -> France
}; };
function effId(d) { function effId(d) {
return TERRITORY_PARENT[d.id] || d.id; 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 frameEl;
let _paths = $state(null);
let _g = null;
let _pathFn = null;
let _countries = null;
function updateHomeMarker(homeCountryName) {
if (!_g || !_pathFn || !_countries) return;
_g.selectAll('.home-marker').remove();
if (!homeCountryName) return;
const found = _countries.find(f => f.properties.name === homeCountryName);
if (!found) return;
const [cx, cy] = _pathFn.centroid(found);
if (isNaN(cx) || isNaN(cy)) return;
const SIZE = 24;
_g.append('image')
.attr('class', 'home-marker')
.attr('href', homeIconUrl)
.attr('x', cx - SIZE / 2)
.attr('y', cy - SIZE / 2)
.attr('width', SIZE)
.attr('height', SIZE)
.style('pointer-events', 'none');
}
$effect(() => {
const homeCountry = getUserProfile()?.homeCountry ?? null;
updateHomeMarker(homeCountry);
});
function fitProjection(proj, w, h) { function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' }); proj.fitSize([w, h], { type: 'Sphere' });
@@ -111,32 +30,6 @@
proj.scale(s).translate([w / 2, h * 0.70]); 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);
$effect(() => {
const flashSet = getFlashing();
const paths = _paths; // reactive read so effect re-runs when _paths is set
if (!paths || flashSet.size === 0) return;
paths
.filter(d => flashSet.has(effId(d)))
.each(function() {
d3.select(this).interrupt()
.transition().duration(200).attr('fill', '#facc15')
.transition().duration(200).attr('fill', '#fb923c')
.transition().duration(200).attr('fill', '#facc15')
.transition().duration(200).attr('fill', '#fb923c')
.transition().duration(200).attr('fill', '#facc15')
.transition().duration(400).attr('fill', VISITED_COLOR);
});
});
onMount(() => { onMount(() => {
const width = frameEl.clientWidth; const width = frameEl.clientWidth;
const height = frameEl.clientHeight; const height = frameEl.clientHeight;
@@ -149,36 +42,30 @@
const countries = feature(worldData, worldData.objects.countries) const countries = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010'); .features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countries.forEach(f => { countries.forEach(f => { if (!f.id) f.id = 'XK'; });
if (!f.id) f.id = 'XK';
});
_pathFn = path;
_countries = countries;
const sovereignIds = new Set(countries.map(f => effId(f))); const sovereignIds = new Set(countries.map(f => effId(f)));
setTotalCount(sovereignIds.size); setTotalCount(sovereignIds.size);
const svg = d3.select(frameEl) const svg = d3.select(frameEl).append('svg').attr('width', width).attr('height', height);
.append('svg') const g = svg.append('g');
.attr('width', width)
.attr('height', height);
_g = svg.append('g'); const tooltip = d3.select(frameEl).append('div').attr('class', 'tooltip').style('display', 'none');
const tooltip = d3.select(frameEl) function updateFill(sel) {
.append('div') sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
.attr('class', 'tooltip') g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
.style('display', 'none'); }
function attachEvents(sel) { function attachEvents(sel) {
sel sel
.on('click', (event, d) => { .on('click', (event, d) => {
toggle(effId(d));
updateFill(d3.select(event.currentTarget));
onCountryClick(d.properties.name); onCountryClick(d.properties.name);
}) })
.on('mouseenter', (event, d) => { .on('mouseenter', (event, d) => {
const s = getSelected(); d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, null));
tooltip.style('display', 'block').text(d.properties.name); tooltip.style('display', 'block').text(d.properties.name);
}) })
.on('mousemove', (event) => { .on('mousemove', (event) => {
@@ -186,55 +73,40 @@
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px'); tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
}) })
.on('mouseleave', (event, d) => { .on('mouseleave', (event, d) => {
const s = getSelected(); d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
d3.select(event.currentTarget).attr('fill', countryColor(d, s, null));
tooltip.style('display', 'none'); tooltip.style('display', 'none');
}); });
} }
_paths = _g.selectAll('path') const paths = g.selectAll('path').data(countries).join('path')
.data(countries) .attr('d', path).attr('fill', '#ffffff').attr('stroke', '#d4d4d4').attr('stroke-width', 0.5);
.join('path') attachEvents(paths);
.attr('d', path)
.attr('fill', '#ffffff')
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5);
attachEvents(_paths);
function renderMicrostates() { function renderMicrostates() {
_g.selectAll('.micro-state').remove(); g.selectAll('.micro-state').remove();
const threshold = Math.max(4, 16 / d3.zoomTransform(svg.node()).k); 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; if (effId(d) !== d.id) return;
const { width, height } = this.getBBox(); const { width, height } = this.getBBox();
if (width < threshold && height < threshold) { if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d); const [cx, cy] = path.centroid(d);
const c = _g.append('circle') const c = g.append('circle').attr('class', 'micro-state').datum(d)
.attr('class', 'micro-state') .attr('cx', cx).attr('cy', cy).attr('r', 2)
.datum(d) .attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff')
.attr('cx', cx) .attr('stroke', '#94a3b8').attr('stroke-width', 0.5);
.attr('cy', cy)
.attr('r', 2)
.attr('fill', countryColor(d, getSelected(), null))
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c); attachEvents(c);
} }
}); });
} }
renderMicrostates(); renderMicrostates();
updateHomeMarker(getUserProfile()?.homeCountry ?? null);
const zoom = d3.zoom() const zoom = d3.zoom().scaleExtent([1, 32]).on('zoom', (event) => {
.scaleExtent([1, 32]) g.attr('transform', event.transform);
.on('zoom', (event) => { renderMicrostates();
_g.attr('transform', event.transform); });
renderMicrostates();
});
svg.call(zoom); svg.call(zoom);
svg.on('dblclick.zoom', null); svg.on('dblclick.zoom', null);
svg.on('dblclick', (event) => { svg.on('dblclick', (event) => {
const [x, y] = d3.pointer(event); const [x, y] = d3.pointer(event);
@@ -246,7 +118,7 @@
const { width, height } = entry.contentRect; const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height); svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height); fitProjection(projection, width, height);
const countryPaths = _g.selectAll('path'); const countryPaths = g.selectAll('path');
countryPaths.attr('d', path); countryPaths.attr('d', path);
updateFill(countryPaths); updateFill(countryPaths);
renderMicrostates(); renderMicrostates();
@@ -262,7 +134,7 @@
}); });
</script> </script>
<div bind:this={frameEl} class="map-frame" style="cursor: url({crayonCursorUrl}) 4 28, crosshair;"></div> <div bind:this={frameEl} class="map-frame"></div>
<style> <style>
.map-frame { .map-frame {
@@ -273,18 +145,9 @@
background: #a4c8e0; background: #a4c8e0;
} }
.map-frame :global(svg) { .map-frame :global(svg) { display: block; cursor: grab; }
display: block; .map-frame :global(svg:active) { cursor: grabbing; }
cursor: inherit; .map-frame :global(svg path) { cursor: pointer; }
}
.map-frame :global(svg:active) {
cursor: inherit;
}
.map-frame :global(svg path) {
cursor: inherit;
}
.map-frame :global(.tooltip) { .map-frame :global(.tooltip) {
position: absolute; position: absolute;