18 Commits

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:54:45 +09:00
haerikimmm
40e75f30e8 changed to centered layout 2026-06-15 19:50:13 +09:00
965f677368 fix after merge 2026-06-15 12:35:57 +09:00
611cc0b626 Merge branch 'feature/timeline' into main 2026-06-15 12:29:36 +09:00
70352be01b animation plane shape 2026-06-15 12:14:57 +09:00
bd001a71fa add jurney animation 2026-06-15 00:06:10 +09:00
c9c94d670f Merge feature/timeline into main 2026-06-12 20:06:25 +09:00
f198c05063 display home country in map 2026-06-12 19:19:20 +09:00
6701398da7 add saving countries 2026-06-12 18:20:00 +09:00
08d3e3ae56 add firebase and sign up 2026-06-12 18:19:45 +09:00
cd682f738a make stats panel colabseble 2026-06-10 22:56:56 +09:00
5356c05654 finished merging 2026-06-09 16:54:31 +09:00
640c241e1c Merge branch 'feature/timeline' 2026-06-09 16:50:37 +09:00
e62b68ede6 small visual changes 2026-06-09 16:18:37 +09:00
65e16f3502 add side stats panel 2026-06-09 15:56:01 +09:00
8976b94c41 add app layout 2026-06-08 22:26:35 +09:00
7a2d488f9c coloring map, first version 2026-06-03 01:08:11 +09:00
38 changed files with 3038 additions and 333 deletions

1
.gitignore vendored
View File

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

0
dev_error.txt Normal file
View File

11
dev_output.txt Normal file
View File

@@ -0,0 +1,11 @@
> 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/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/logo.png" />
<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": true "checkJs": false
}, },
/** /**
* 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,6 +15,7 @@
}, },
"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",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

BIN
public/plane.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
public/profile.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,37 +1,100 @@
<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/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('');
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>
<Layout {screen} onNavigate={(s) => (screen = s)} hideTopBar={inDetail}> {#if loading}
{#if screen === 'worldmap'} <div class="loading-screen">
<div class="worldmap-page"> <span class="loading-text">Loading...</span>
<div class="map-area"> </div>
<WorldMap onCountryClick={handleCountryClick} /> {:else}
<Layout {screen} {onNavigate} hideTopBar={inDetail}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area">
{#if journeyActive}
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} />
{:else}
<WorldMap onCountryClick={handleCountryClick} />
<button class="journey-play-btn" onclick={startJourney}>▶</button>
{/if}
</div>
<StatsPanel />
</div> </div>
<StatsPanel /> {:else}
</div> <TimelineView
{:else} onDetailChange={(v) => (inDetail = v)}
<TimelineView {pendingCountry}
onDetailChange={(v) => (inDetail = v)} onNewEntryClear={() => (pendingCountry = '')}
{pendingCountry} />
onNewEntryClear={() => (pendingCountry = '')} {/if}
/> </Layout>
{#if !user}
<LoginOverlay />
{:else if needsCountry}
<CountryPicker />
{/if} {/if}
</Layout> {/if}
<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;
@@ -44,5 +107,36 @@
.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;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: #8b5cf6;
color: #fff;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.4);
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
}
.journey-play-btn:hover {
background: #7c3aed;
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
}
.journey-play-btn:active {
transform: scale(0.92);
} }
</style> </style>

BIN
src/assets/airplane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/assets/bus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
src/assets/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

BIN
src/assets/ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/train.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

BIN
src/assets/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

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

View File

@@ -0,0 +1,87 @@
<script>
import { signInWithGoogle } from './userStore.svelte.js';
</script>
<div class="overlay">
<div class="card">
<img src="/logo.png" alt="Map Journal" class="logo" />
<h1 class="title">Map Journal</h1>
<p class="subtitle">Sign in to start your journey</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: rgba(15, 23, 42, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(4px);
}
.card {
background: #1e2937;
border-radius: 16px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
}
.logo {
width: 80px;
height: 80px;
border-radius: 12px;
margin-bottom: 16px;
}
.title {
font: 700 28px/1.2 sans-serif;
color: #f1f5f9;
margin-bottom: 8px;
}
.subtitle {
font: 400 15px/1.4 sans-serif;
color: #94a3b8;
margin-bottom: 32px;
}
.google-btn {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 28px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
background: #334155;
color: #f1f5f9;
font: 500 16px/1.4 sans-serif;
cursor: pointer;
transition: background 0.2s;
}
.google-btn:hover {
background: #475569;
}
.google-icon {
width: 22px;
height: 22px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,73 @@
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 { initSelectionListener } from '../layout/selection.svelte.js';
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;
initSelectionListener(fbUser.uid);
initEntriesListener(fbUser.uid);
const docRef = doc(db, 'users', fbUser.uid);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
userProfile = docSnap.data();
needsCountry = false;
} else {
userProfile = {
displayName: fbUser.displayName,
photoURL: fbUser.photoURL,
email: fbUser.email,
};
needsCountry = true;
}
} else {
user = null;
userProfile = null;
needsCountry = false;
}
loading = false;
});
}

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

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

View File

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

View File

@@ -1,18 +1,58 @@
import { db } from '../firebase.js';
import { doc, onSnapshot, setDoc, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
let selected = $state(new Set()); let selected = $state(new Set());
let totalCountries = $state(0); let totalCountries = $state(0);
let homeCountryCode = $state(null);
let _uid = null;
let _unsubscribe = null;
export function initSelectionListener(uid) {
if (_unsubscribe) _unsubscribe();
_uid = uid;
const userRef = doc(db, 'users', uid);
_unsubscribe = onSnapshot(userRef, (snap) => {
if (snap.exists()) {
const codes = snap.data().visitedCountries || [];
selected = new Set(codes);
homeCountryCode = snap.data().homeCountryCode || null;
}
});
}
const visitedRef = doc(db, 'visited', 'countries');
onSnapshot(visitedRef, (snap) => {
if (snap.exists()) {
selected = new Set(snap.data().ids ?? []);
}
});
function persist() {
setDoc(visitedRef, { ids: [...selected] });
}
export function toggle(id) { export function toggle(id) {
const was = selected.has(id);
const next = new Set(selected); const next = new Set(selected);
if (next.has(id)) { if (was) next.delete(id);
next.delete(id); else next.add(id);
} else {
next.add(id);
}
selected = next; selected = next;
persist();
if (_uid) {
const userRef = doc(db, 'users', _uid);
if (was) updateDoc(userRef, { visitedCountries: arrayRemove(id) });
else updateDoc(userRef, { visitedCountries: arrayUnion(id) });
}
} }
export function clearAll() { export function clearAll() {
selected = new Set(); selected = new Set();
persist();
if (_uid) {
const userRef = doc(db, 'users', _uid);
updateDoc(userRef, { visitedCountries: [] });
}
} }
export function getSelected() { export function getSelected() {
@@ -26,3 +66,7 @@ export function setTotalCount(n) {
export function getTotalCount() { export function getTotalCount() {
return totalCountries; return totalCountries;
} }
export function getHomeCountryCode() {
return homeCountryCode;
}

View File

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

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

View File

@@ -1,4 +1,8 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { db } from '../firebase.js';
import {
collection, onSnapshot, addDoc, updateDoc, deleteDoc, doc, serverTimestamp
} from 'firebase/firestore';
/** /**
* @typedef {{ * @typedef {{
@@ -14,110 +18,28 @@ import { writable } from 'svelte/store';
* }} JournalEntry * }} JournalEntry
*/ */
/** @type {JournalEntry[]} */ export const journals = writable(/** @type {JournalEntry[]} */([]));
const mockEntries = [ export const journalsLoading = writable(true);
{
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); const entriesRef = collection(db, 'entries');
onSnapshot(entriesRef, (snap) => {
journals.set(snap.docs.map(d => ({ id: d.id, ...d.data() })));
journalsLoading.set(false);
});
/** @param {Omit<JournalEntry, 'id'>} entry */ /** @param {Omit<JournalEntry, 'id'>} entry */
export function addJournal(entry) { export async function addJournal(entry) {
journals.update((entries) => [...entries, { ...entry, id: crypto.randomUUID() }]); await addDoc(entriesRef, { ...entry, createdAt: serverTimestamp() });
} }
/** @param {string} id */ /** @param {string} id */
export function removeJournal(id) { export async function removeJournal(id) {
journals.update((entries) => entries.filter((e) => e.id !== id)); await deleteDoc(doc(db, 'entries', id));
} }
/** @param {JournalEntry} updated */ /** @param {JournalEntry} updated */
export function updateJournal(updated) { export async function updateJournal(updated) {
journals.update((entries) => entries.map((e) => e.id === updated.id ? updated : e)); const { id, ...data } = updated;
await updateDoc(doc(db, 'entries', id), data);
} }

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 entry?</h2> <h2 class="title">Delete trip?</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

@@ -18,11 +18,19 @@
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 ?? 1)); let days = $state(String(entry?.days ?? ''));
let tripType = $state(entry?.tripType ?? 'solo'); let tripType = $state(entry?.tripType ?? '');
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 ?? 'flight'); let transport = $state(entry?.transport ?? '');
let errors = $state({
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
});
function clearErrors() {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
}
const transportOptions = [ const transportOptions = [
{ value: 'flight', label: '✈ Flight' }, { value: 'flight', label: '✈ Flight' },
@@ -49,8 +57,11 @@
} }
} }
// Suggest cities — when a country is selected show only cities from that country.
let cityOptions = $derived( let cityOptions = $derived(
[...new Set(get(journals).flatMap(e => e.location.cities))].sort() country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
); );
function addCity(val) { function addCity(val) {
@@ -65,31 +76,45 @@
cities = cities.filter(x => x !== c); cities = cities.filter(x => x !== c);
} }
function save() { async function save() {
if (isNew) { clearErrors();
addJournal({ let hasError = false;
title: `${cities.join(', ')}, ${country}`, if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
date, if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
days: Number(days), if (!date) { errors.date = 'Date is required.'; hasError = true; }
tripType, if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
memo, if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
photos, if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
transport, if (hasError) return;
location: { cities, country },
}); try {
} else { if (isNew) {
updateJournal({ await addJournal({
...entry, title: `${cities.join(', ')}, ${country}`,
date, date,
days: Number(days), days: Number(days),
tripType, tripType,
transport, memo,
memo, photos,
photos, transport,
location: { cities, country }, location: { cities, country },
}); });
} else {
await updateJournal({
...entry,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
}
onBack();
} catch (err) {
showToast('Failed to save. Please try again.');
} }
onBack();
} }
</script> </script>
@@ -104,7 +129,7 @@
Back Back
</button> </button>
</div> </div>
<span class="topbar-title">{isNew ? 'New entry' : 'Edit'}</span> <span class="topbar-title">{isNew ? 'New trip' : '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>
@@ -117,12 +142,14 @@
<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}
@@ -140,10 +167,12 @@
<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>
@@ -160,6 +189,7 @@
<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">
@@ -172,6 +202,7 @@
</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)} />
@@ -190,6 +221,12 @@
</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;

View File

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

View File

@@ -1,32 +1,35 @@
<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) {
const next = photos.filter((_, i) => i !== index); onchange(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;
const dataUrls = await Promise.all(files.map(fileToDataUrl)); try {
onchange([...photos, ...dataUrls]); const urls = await Promise.all(files.map(uploadPhoto));
onchange([...photos, ...urls]);
// reset so the same file can be picked again } finally {
e.currentTarget.value = ''; uploading = false;
e.currentTarget.value = '';
}
} }
/** @param {File} file */ /** @param {File} file */
function fileToDataUrl(file) { async function uploadPhoto(file) {
return new Promise((resolve) => { const storageRef = ref(storage, `photos/${crypto.randomUUID()}`);
const reader = new FileReader(); await uploadBytes(storageRef, file);
reader.onload = (e) => resolve(/** @type {string} */ (e.target.result)); return getDownloadURL(storageRef);
reader.readAsDataURL(file);
});
} }
</script> </script>
@@ -37,13 +40,13 @@
</div> </div>
{#if photos.length === 0} {#if photos.length === 0}
<button type="button" class="empty-zone" onclick={() => fileInput.click()}> <button type="button" class="empty-zone" onclick={() => fileInput.click()} disabled={uploading}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"> <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>Click to add photos</span> <span>{uploading ? 'Uploading…' : 'Click to add photos'}</span>
</button> </button>
{:else} {:else}
<div class="grid"> <div class="grid">
@@ -58,7 +61,7 @@
</div> </div>
{/each} {/each}
<button type="button" class="add-cell" onclick={() => fileInput.click()}> <button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <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

@@ -5,6 +5,7 @@
import TimelineCard from './TimelineCard.svelte'; import TimelineCard from './TimelineCard.svelte';
import JournalDetail from './JournalDetail.svelte'; import JournalDetail from './JournalDetail.svelte';
import EditForm from './EditForm.svelte'; import EditForm from './EditForm.svelte';
import NewEntryForm from './NewEntryForm.svelte';
import ShareCard from './ShareCard.svelte'; import ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte'; import SharePreview from './SharePreview.svelte';
@@ -54,7 +55,7 @@
{#if view === 'new'} {#if view === 'new'}
<div class="detail-scroll"> <div class="detail-scroll">
<EditForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} /> <NewEntryForm 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">
@@ -70,56 +71,52 @@
</div> </div>
{:else} {:else}
<div class="right-panel"> <div class="right-panel">
<div class="two-col"> <div class="center-col">
<!-- Timeline column --> <div class="page-header">
<div class="timeline-col"> <h1 class="page-title">My Journey</h1>
<div class="page-header"> <button class="new-btn" onclick={() => { view = 'new'; }}>
<h1 class="page-title">My Journey</h1> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<button class="new-btn" onclick={() => { view = 'new'; }}> <path d="M12 5v14M5 12h14"/>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"> </svg>
<path d="M12 5v14M5 12h14"/> Add trip
</svg> </button>
New entry
</button>
</div>
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}
<div class="sort-row">
<span class="sort-label">Sort by</span>
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A Z</option>
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z A</option>
</select>
</div>
<ol class="v-list">
{#each sortedEntries as entry, i (entry.id)}
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
<li class="year-marker" aria-hidden="true">
<span class="year-label">{getYear(entry.date)}</span>
</li>
{/if}
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
{/each}
</ol>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
</footer>
</div> </div>
<!-- Share preview column -->
{#if sortedEntries.length > 0} {#if sortedEntries.length > 0}
<div class="share-col"> <div class="share-row">
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} /> <SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
</div> </div>
{/if} {/if}
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}
<div class="sort-row">
<span class="sort-label">Sort by</span>
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A Z</option>
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z A</option>
</select>
</div>
<ol class="v-list">
{#each sortedEntries as entry, i (entry.id)}
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
<li class="year-marker" aria-hidden="true">
<span class="year-label">{getYear(entry.date)}</span>
</li>
{/if}
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
{/each}
</ol>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
</footer>
</div> </div>
</div> </div>
{/if} {/if}
@@ -148,30 +145,23 @@
background: var(--bg); background: var(--bg);
} }
/* ── Two-column layout ── */ /* ── Centered single column ── */
.two-col { .center-col {
display: grid; max-width: 680px;
grid-template-columns: 1fr 240px;
gap: 48px;
max-width: 1020px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 48px 48px 80px; padding: 48px 48px 80px;
align-items: start;
box-sizing: border-box; box-sizing: border-box;
} }
.timeline-col { min-width: 0; } .share-row {
margin-bottom: 24px;
}
.share-col { padding-top: 60px; } @media (max-width: 760px) {
.center-col {
/* ── Responsive: narrow viewport ── */
@media (max-width: 860px) {
.two-col {
grid-template-columns: 1fr;
padding: 32px 24px 60px; padding: 32px 24px 60px;
} }
.share-col { padding-top: 0; }
} }
/* ── Detail view ── */ /* ── Detail view ── */

View File

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

View File

@@ -3,26 +3,78 @@
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, toggle, setTotalCount } from '../layout/selection.svelte.js'; import { getSelected, toggle, setTotalCount, getHomeCountryCode } from '../layout/selection.svelte.js';
let { onCountryClick = (_name) => {} } = $props(); let { onCountryClick = (_name) => {} } = $props();
const TERRITORY_PARENT = { const TERRITORY_PARENT = {
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826', '016': '840', // American Samoa -> United States
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246', '060': '826', // Bermuda -> United Kingdom
'258': '250', '260': '250', '304': '208', '316': '840', '334': '036', '086': '826', // Br. Indian Ocean Ter. -> United Kingdom
'446': '156', '500': '826', '531': '528', '533': '528', '534': '528', '092': '826', // British Virgin Is. -> United Kingdom
'540': '250', '570': '554', '574': '036', '580': '840', '612': '826', '136': '826', // Cayman Is. -> United Kingdom
'630': '840', '652': '250', '654': '826', '660': '826', '663': '250', '184': '554', // Cook Is. -> New Zealand
'666': '250', '796': '826', '831': '826', '832': '826', '833': '826', '234': '208', // Faeroe Is. -> Denmark
'850': '840', '876': '250', '238': '826', // Falkland Is. -> United Kingdom
'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 = null;
let _g = null;
function fitProjection(proj, w, h) { function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' }); proj.fitSize([w, h], { type: 'Sphere' });
@@ -30,6 +82,16 @@
proj.scale(s).translate([w / 2, h * 0.70]); proj.scale(s).translate([w / 2, h * 0.70]);
} }
function updateAllFills() {
const sel = getSelected();
const hc = getHomeCountryCode();
if (!_paths || !_g) return;
_paths.attr('fill', d => countryColor(d, sel, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
}
$effect(updateAllFills);
onMount(() => { onMount(() => {
const width = frameEl.clientWidth; const width = frameEl.clientWidth;
const height = frameEl.clientHeight; const height = frameEl.clientHeight;
@@ -42,19 +104,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 => { if (!f.id) f.id = 'XK'; }); countries.forEach(f => {
if (!f.id) f.id = 'XK';
});
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).append('svg').attr('width', width).attr('height', height); const svg = d3.select(frameEl)
const g = svg.append('g'); .append('svg')
.attr('width', width)
.attr('height', height);
const tooltip = d3.select(frameEl).append('div').attr('class', 'tooltip').style('display', 'none'); _g = svg.append('g');
const tooltip = d3.select(frameEl)
.append('div')
.attr('class', 'tooltip')
.style('display', 'none');
function updateFill(sel) { function updateFill(sel) {
sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff'); const s = getSelected();
g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff'); const hc = getHomeCountryCode();
sel.attr('fill', d => countryColor(d, s, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, s, hc));
} }
function attachEvents(sel) { function attachEvents(sel) {
@@ -65,7 +138,9 @@
onCountryClick(d.properties.name); onCountryClick(d.properties.name);
}) })
.on('mouseenter', (event, d) => { .on('mouseenter', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa'); const s = getSelected();
const hc = getHomeCountryCode();
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
tooltip.style('display', 'block').text(d.properties.name); tooltip.style('display', 'block').text(d.properties.name);
}) })
.on('mousemove', (event) => { .on('mousemove', (event) => {
@@ -73,27 +148,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) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff'); const s = getSelected();
const hc = getHomeCountryCode();
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
tooltip.style('display', 'none'); tooltip.style('display', 'none');
}); });
} }
const paths = g.selectAll('path').data(countries).join('path') _paths = _g.selectAll('path')
.attr('d', path).attr('fill', '#ffffff').attr('stroke', '#d4d4d4').attr('stroke-width', 0.5); .data(countries)
attachEvents(paths); .join('path')
.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').attr('class', 'micro-state').datum(d) const hc = getHomeCountryCode();
.attr('cx', cx).attr('cy', cy).attr('r', 2) const c = _g.append('circle')
.attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff') .attr('class', 'micro-state')
.attr('stroke', '#94a3b8').attr('stroke-width', 0.5); .datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 2)
.attr('fill', countryColor(d, getSelected(), hc))
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c); attachEvents(c);
} }
}); });
@@ -101,12 +189,15 @@
renderMicrostates(); renderMicrostates();
const zoom = d3.zoom().scaleExtent([1, 32]).on('zoom', (event) => { const zoom = d3.zoom()
g.attr('transform', event.transform); .scaleExtent([1, 32])
renderMicrostates(); .on('zoom', (event) => {
}); _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);
@@ -118,7 +209,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();
@@ -145,9 +236,18 @@
background: #a4c8e0; background: #a4c8e0;
} }
.map-frame :global(svg) { display: block; cursor: grab; } .map-frame :global(svg) {
.map-frame :global(svg:active) { cursor: grabbing; } display: block;
.map-frame :global(svg path) { cursor: pointer; } cursor: grab;
}
.map-frame :global(svg:active) {
cursor: grabbing;
}
.map-frame :global(svg path) {
cursor: pointer;
}
.map-frame :global(.tooltip) { .map-frame :global(.tooltip) {
position: absolute; position: absolute;