Compare commits
48 Commits
feature/st
...
1743e7fcbe
| Author | SHA1 | Date | |
|---|---|---|---|
| 1743e7fcbe | |||
| d614ddb322 | |||
|
|
ed415a78a1 | ||
|
|
9109d6a861 | ||
|
|
5a95fccd70 | ||
| 8d36c3faca | |||
|
|
b518016a21 | ||
|
|
665472b281 | ||
|
|
2226a483c5 | ||
|
|
93636b6968 | ||
|
|
5718bca963 | ||
|
|
6f41f6e53e | ||
|
|
d157055ab7 | ||
|
|
76d7e815c3 | ||
|
|
c7cf053105 | ||
|
|
a7079c1f18 | ||
|
|
cf9717149f | ||
|
|
ec4eea0977 | ||
|
|
92fae28383 | ||
|
|
b3c5fbe3dd | ||
|
|
8e9b40cc69 | ||
|
|
d389b496b4 | ||
|
|
bf2700efb7 | ||
| 0a823948df | |||
|
|
d2fb40f692 | ||
| 36f0c25721 | |||
| d09946161f | |||
| 87993ae9c6 | |||
| 65248fd082 | |||
| e9662754c4 | |||
|
|
06e5fe5593 | ||
|
|
dd7932ea4e | ||
|
|
40e75f30e8 | ||
| 965f677368 | |||
| 611cc0b626 | |||
| 70352be01b | |||
| bd001a71fa | |||
| c9c94d670f | |||
| f198c05063 | |||
| 6701398da7 | |||
| 08d3e3ae56 | |||
| cd682f738a | |||
| 5356c05654 | |||
| 640c241e1c | |||
| e62b68ede6 | |||
| 65e16f3502 | |||
| 8976b94c41 | |||
| 7a2d488f9c |
@@ -3,7 +3,7 @@
|
|||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Map-Jurnal",
|
"name": "Map-Jurnal",
|
||||||
"cwd": "/Users/haerikim/Desktop/Map-Jurnal",
|
"cwd": ".",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 5173,
|
"port": 5173,
|
||||||
|
|||||||
5
.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "map-jurnal"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
7
cors.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"origin": ["http://localhost:5173", "https://map-jurnal.web.app", "https://map-jurnal.firebaseapp.com"],
|
||||||
|
"method": ["GET", "HEAD", "PUT", "POST", "DELETE"],
|
||||||
|
"maxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
0
dev_error.txt
Normal file
11
dev_output.txt
Normal 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...
|
||||||
|
|
||||||
|
[32m[1mVITE[22m v8.0.15[39m [2mready in [0m[1m1792[22m[2m[0m ms[22m
|
||||||
|
|
||||||
|
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5175[22m/[39m
|
||||||
|
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||||
5
firebase.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"storage": {
|
||||||
|
"rules": "storage.rules"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2377
package-lock.json
generated
@@ -9,12 +9,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.5",
|
||||||
"vite": "^8.0.12"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -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
|
After Width: | Height: | Size: 102 KiB |
11
scripts/get-token.mjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { getAccessToken } from 'firebase-tools';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
writeFileSync('token.txt', token);
|
||||||
|
console.log('Token saved');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed:', e.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
36
scripts/set-cors.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(
|
||||||
|
'C:\\Users\\tomas\\.config\\configstore\\firebase-tools.json', 'utf-8'
|
||||||
|
));
|
||||||
|
const token = config.tokens.access_token;
|
||||||
|
|
||||||
|
// Try to list buckets first
|
||||||
|
const listRes = await fetch('https://storage.googleapis.com/storage/v1/b?project=map-jurnal', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const buckets = await listRes.json();
|
||||||
|
console.log('Buckets:', buckets.items?.map(b => b.name) ?? buckets);
|
||||||
|
|
||||||
|
const bucket = 'map-jurnal.appspot.com';
|
||||||
|
const corsConfig = [{
|
||||||
|
origin: ['http://localhost:5173', 'https://map-jurnal.web.app', 'https://map-jurnal.firebaseapp.com'],
|
||||||
|
method: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'],
|
||||||
|
maxAgeSeconds: 3600,
|
||||||
|
}];
|
||||||
|
|
||||||
|
const res = await fetch(`https://storage.googleapis.com/storage/v1/b/${bucket}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ cors: corsConfig }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
console.log('CORS configured successfully!');
|
||||||
|
} else {
|
||||||
|
const text = await res.text();
|
||||||
|
console.error('Failed:', res.status, text);
|
||||||
|
}
|
||||||
32
scripts/set-cors2.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { Storage } from '@google-cloud/storage';
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync('C:/Users/tomas/.config/configstore/firebase-tools.json', 'utf-8'));
|
||||||
|
|
||||||
|
const storage = new Storage({
|
||||||
|
projectId: 'map-jurnal',
|
||||||
|
credentials: {
|
||||||
|
client_email: 'firebase-cli@map-jurnal.iam.gserviceaccount.com',
|
||||||
|
// We'll use token-based auth instead
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try with direct token
|
||||||
|
const token = config.tokens.access_token;
|
||||||
|
|
||||||
|
// List all buckets to find the right name
|
||||||
|
const [buckets] = await storage.getBuckets({ project: 'map-jurnal' });
|
||||||
|
console.log('Buckets:', buckets.map(b => b.name));
|
||||||
|
|
||||||
|
if (buckets.length > 0) {
|
||||||
|
const bucket = buckets[0];
|
||||||
|
console.log('Configuring CORS for:', bucket.name);
|
||||||
|
await bucket.setCorsConfiguration([
|
||||||
|
{
|
||||||
|
origin: ['http://localhost:5173', 'https://map-jurnal.web.app', 'https://map-jurnal.firebaseapp.com'],
|
||||||
|
method: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'],
|
||||||
|
maxAgeSeconds: 3600,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
console.log('CORS configured!');
|
||||||
|
}
|
||||||
128
src/App.svelte
@@ -1,47 +1,145 @@
|
|||||||
<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/view/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>
|
||||||
|
|
||||||
<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} 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>
|
||||||
<StatsPanel />
|
{:else}
|
||||||
</div>
|
<TimelineView
|
||||||
{:else}
|
onDetailChange={(v) => (inDetail = v)}
|
||||||
<TimelineView
|
{pendingCountry}
|
||||||
onDetailChange={(v) => (inDetail = v)}
|
onNewEntryClear={() => (pendingCountry = '')}
|
||||||
{pendingCountry}
|
onGoToMap={() => { screen = 'worldmap'; }}
|
||||||
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;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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: 500;
|
||||||
|
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>
|
||||||
|
|||||||
BIN
src/assets/airplane-animation.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/airplane.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
src/assets/bus.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
src/assets/car.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
src/assets/default-1.jpeg
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
src/assets/default-2.jpeg
Normal file
|
After Width: | Height: | Size: 663 KiB |
BIN
src/assets/default-3.jpeg
Normal file
|
After Width: | Height: | Size: 566 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
src/assets/home.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
src/assets/logo-1-cursor.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/logo-1.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
src/assets/logo-2.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
src/assets/logo-cursor.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/logo-signin.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src/assets/profile.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
src/assets/ship.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
@@ -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
|
After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
BIN
src/assets/walk.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
190
src/lib/auth/CountryPicker.svelte
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<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>
|
||||||
114
src/lib/auth/LoginOverlay.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script>
|
||||||
|
import { signInWithGoogle } from './userStore.svelte.js';
|
||||||
|
import logoImg from '../../assets/logo-signin.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>
|
||||||
71
src/lib/auth/userStore.svelte.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { auth, db, googleProvider } from '../firebase.js';
|
||||||
|
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
|
||||||
|
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
|
||||||
|
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
|
||||||
|
|
||||||
|
let _initialized = false;
|
||||||
|
|
||||||
|
let user = $state(null);
|
||||||
|
let userProfile = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let needsCountry = $state(false);
|
||||||
|
|
||||||
|
export function getUser() { return user; }
|
||||||
|
export function getUserProfile() { return userProfile; }
|
||||||
|
export function getLoading() { return loading; }
|
||||||
|
export function getNeedsCountry() { return needsCountry; }
|
||||||
|
|
||||||
|
export async function signInWithGoogle() {
|
||||||
|
await signInWithPopup(auth, googleProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
|
await fbSignOut(auth);
|
||||||
|
user = null;
|
||||||
|
userProfile = null;
|
||||||
|
needsCountry = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setHomeCountry(name, code) {
|
||||||
|
if (!user) return;
|
||||||
|
await setDoc(doc(db, 'users', user.uid), {
|
||||||
|
displayName: user.displayName,
|
||||||
|
photoURL: user.photoURL,
|
||||||
|
email: user.email,
|
||||||
|
homeCountry: name,
|
||||||
|
homeCountryCode: code,
|
||||||
|
visitedCountries: [code],
|
||||||
|
createdAt: serverTimestamp(),
|
||||||
|
});
|
||||||
|
userProfile = { ...userProfile, homeCountry: name, homeCountryCode: code, visitedCountries: [code] };
|
||||||
|
needsCountry = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAuth() {
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
onAuthStateChanged(auth, async (fbUser) => {
|
||||||
|
if (fbUser) {
|
||||||
|
user = fbUser;
|
||||||
|
initEntriesListener(fbUser.uid);
|
||||||
|
const docRef = doc(db, 'users', fbUser.uid);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
userProfile = docSnap.data();
|
||||||
|
needsCountry = false;
|
||||||
|
} else {
|
||||||
|
userProfile = {
|
||||||
|
displayName: fbUser.displayName,
|
||||||
|
photoURL: fbUser.photoURL,
|
||||||
|
email: fbUser.email,
|
||||||
|
};
|
||||||
|
needsCountry = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user = null;
|
||||||
|
userProfile = null;
|
||||||
|
needsCountry = false;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/lib/firebase.js
Normal 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();
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import TopBar from './TopBar.svelte';
|
import TopBar from './TopBar.svelte';
|
||||||
import Footer from './Footer.svelte';
|
|
||||||
|
|
||||||
let { screen, onNavigate, hideTopBar = false, children } = $props();
|
let { screen, onNavigate, hideTopBar = false, children } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -12,7 +11,6 @@
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -20,11 +18,11 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.layout.no-topbar {
|
.layout.no-topbar {
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -1,73 +1,235 @@
|
|||||||
<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>
|
<div class="brand">
|
||||||
|
<span class="app-name">Journi</span>
|
||||||
|
</div>
|
||||||
</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 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 {
|
||||||
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 {
|
.brand {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.center {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
justify-content: center;
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn {
|
.segmented {
|
||||||
font-family: var(--sans);
|
position: relative;
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
font-weight: 300;
|
background: var(--bg-subtle);
|
||||||
padding: 4px 18px;
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
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;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
.nav-btn:hover { color: var(--text-h); }
|
|
||||||
.nav-btn.active {
|
.segmented button.active {
|
||||||
background: #7c3aed;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 1px 4px rgba(124,58,237,0.25);
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
|
import { journals } from '../stores/journalStore.js';
|
||||||
|
import { nameToId } from '../shared/countries.js';
|
||||||
|
import { getUserProfile } from '../auth/userStore.svelte.js';
|
||||||
|
|
||||||
let selected = $state(new Set());
|
let selected = $state(new Set());
|
||||||
let totalCountries = $state(0);
|
let totalCountries = $state(0);
|
||||||
|
let flashing = $state(new Set());
|
||||||
|
|
||||||
export function toggle(id) {
|
journals.subscribe((entries) => {
|
||||||
const next = new Set(selected);
|
const ids = new Set();
|
||||||
if (next.has(id)) {
|
for (const e of entries) {
|
||||||
next.delete(id);
|
const id = nameToId[e.location?.country];
|
||||||
} else {
|
if (id) ids.add(id);
|
||||||
next.add(id);
|
|
||||||
}
|
}
|
||||||
selected = next;
|
const profile = getUserProfile();
|
||||||
}
|
if (profile?.homeCountry) {
|
||||||
|
const homeId = nameToId[profile.homeCountry];
|
||||||
export function clearAll() {
|
if (homeId) ids.add(homeId);
|
||||||
selected = new Set();
|
}
|
||||||
}
|
selected = ids;
|
||||||
|
});
|
||||||
|
|
||||||
export function getSelected() {
|
export function getSelected() {
|
||||||
return selected;
|
return selected;
|
||||||
@@ -26,3 +31,16 @@ 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } = $props();
|
let { id, value = $bindable(), options, placeholder = '', required = false, onselect, onblurcommit } = $props();
|
||||||
|
|
||||||
let query = $state(value);
|
let query = $state(value);
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
value = opt;
|
value = opt;
|
||||||
open = false;
|
open = false;
|
||||||
focused = -1;
|
focused = -1;
|
||||||
|
onselect?.(opt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput(e) {
|
function onInput(e) {
|
||||||
@@ -33,12 +34,16 @@
|
|||||||
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
|
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); }
|
if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); }
|
||||||
else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); }
|
else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); }
|
||||||
else if (e.key === 'Enter' && focused >= 0) { e.preventDefault(); select(filtered[focused]); }
|
else if (e.key === 'Enter') { e.preventDefault(); if (focused >= 0) { select(filtered[focused]); } else if (query.trim()) { select(query.trim()); } }
|
||||||
else if (e.key === 'Escape') { open = false; focused = -1; }
|
else if (e.key === 'Escape') { open = false; focused = -1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlur() {
|
function onBlur() {
|
||||||
setTimeout(() => { open = false; focused = -1; }, 150);
|
setTimeout(() => {
|
||||||
|
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
|
||||||
|
|||||||
209
src/lib/shared/cities.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
export const ALL_CITIES = {
|
||||||
|
'Afghanistan': ['Kabul', 'Herat', 'Kandahar', 'Mazar-i-Sharif', 'Jalalabad'],
|
||||||
|
'Albania': ['Tirana', 'Durrës', 'Vlorë', 'Shkodër', 'Sarandë'],
|
||||||
|
'Algeria': ['Algiers', 'Oran', 'Constantine', 'Annaba', 'Tlemcen'],
|
||||||
|
'Angola': ['Luanda', 'Huambo', 'Benguela', 'Lubango', 'Malanje'],
|
||||||
|
'Argentina': ['Buenos Aires', 'Córdoba', 'Rosario', 'Mendoza', 'Bariloche', 'Salta', 'Ushuaia', 'Mar del Plata', 'Iguazú'],
|
||||||
|
'Armenia': ['Yerevan', 'Gyumri', 'Vanadzor', 'Vagharshapat'],
|
||||||
|
'Australia': ['Sydney', 'Melbourne', 'Brisbane', 'Perth', 'Adelaide', 'Gold Coast', 'Cairns', 'Hobart', 'Darwin', 'Canberra', 'Newcastle'],
|
||||||
|
'Austria': ['Vienna', 'Salzburg', 'Innsbruck', 'Graz', 'Linz', 'Hallstatt', 'Zell am See'],
|
||||||
|
'Azerbaijan': ['Baku', 'Ganja', 'Sumqayit', 'Mingachevir', 'Nakhchivan'],
|
||||||
|
'Bahamas': ['Nassau', 'Freeport', 'Marsh Harbour', 'George Town'],
|
||||||
|
'Bahrain': ['Manama', 'Muharraq', 'Riffa', 'Hamad Town'],
|
||||||
|
'Bangladesh': ['Dhaka', 'Chittagong', 'Sylhet', 'Cox\'s Bazar', 'Rajshahi', 'Khulna'],
|
||||||
|
'Barbados': ['Bridgetown', 'Speightstown', 'Oistins', 'Holetown'],
|
||||||
|
'Belarus': ['Minsk', 'Brest', 'Grodno', 'Vitebsk', 'Gomel'],
|
||||||
|
'Belgium': ['Brussels', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Liège', 'Namur'],
|
||||||
|
'Belize': ['Belize City', 'San Ignacio', 'Belmopan', 'Placencia', 'Caye Caulker'],
|
||||||
|
'Benin': ['Porto-Novo', 'Cotonou', 'Parakou', 'Abomey'],
|
||||||
|
'Bhutan': ['Thimphu', 'Paro', 'Punakha', 'Jakar'],
|
||||||
|
'Bolivia': ['La Paz', 'Sucre', 'Santa Cruz', 'Cochabamba', 'Uyuni', 'Potosí'],
|
||||||
|
'Bosnia and Herz.': ['Sarajevo', 'Mostar', 'Banja Luka', 'Tuzla', 'Zenica'],
|
||||||
|
'Botswana': ['Gaborone', 'Maun', 'Kasane', 'Francistown'],
|
||||||
|
'Brazil': ['Rio de Janeiro', 'São Paulo', 'Brasília', 'Salvador', 'Fortaleza', 'Recife', 'Porto Alegre', 'Curitiba', 'Manaus', 'Florianópolis', 'Belo Horizonte', 'Iguaçu Falls', 'Paraty', 'Bonito'],
|
||||||
|
'Brunei': ['Bandar Seri Begawan', 'Kuala Belait', 'Seria', 'Tutong'],
|
||||||
|
'Bulgaria': ['Sofia', 'Plovdiv', 'Varna', 'Burgas', 'Ruse', 'Bansko'],
|
||||||
|
'Burkina Faso': ['Ouagadougou', 'Bobo-Dioulasso', 'Koudougou', 'Banfora'],
|
||||||
|
'Burundi': ['Bujumbura', 'Gitega', 'Ngozi', 'Ruyigi'],
|
||||||
|
'Cabo Verde': ['Praia', 'Mindelo', 'Santa Maria', 'Sal Rei'],
|
||||||
|
'Cambodia': ['Phnom Penh', 'Siem Reap', 'Sihanoukville', 'Battambang', 'Kampot'],
|
||||||
|
'Cameroon': ['Yaoundé', 'Douala', 'Bamenda', 'Garoua', 'Kribi'],
|
||||||
|
'Canada': ['Toronto', 'Vancouver', 'Montreal', 'Calgary', 'Ottawa', 'Quebec City', 'Halifax', 'Whistler', 'Banff', 'Victoria', 'Edmonton'],
|
||||||
|
'Chad': ['N\'Djamena', 'Moundou', 'Sarh', 'Abéché'],
|
||||||
|
'Chile': ['Santiago', 'Valparaíso', 'Viña del Mar', 'Puerto Varas', 'San Pedro de Atacama', 'Punta Arenas', 'Easter Island (Rapa Nui)', 'Concepción', 'La Serena'],
|
||||||
|
'China': ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Chengdu', 'Hangzhou', 'Xi\'an', 'Kunming', 'Zhangjiajie', 'Guilin', 'Hong Kong', 'Macao', 'Lhasa', 'Suzhou', 'Nanjing', 'Chongqing', 'Wuhan', 'Harbin'],
|
||||||
|
'Colombia': ['Bogotá', 'Medellín', 'Cartagena', 'Cali', 'Santa Marta', 'Bucaramanga', 'San Andrés', 'Leticia', 'Tayrona'],
|
||||||
|
'Congo': ['Brazzaville', 'Pointe-Noire', 'Dolisie', 'Ouésso'],
|
||||||
|
'Costa Rica': ['San José', 'Liberia', 'Puerto Viejo', 'La Fortuna', 'Monteverde', 'Manuel Antonio', 'Tamarindo'],
|
||||||
|
'Croatia': ['Zagreb', 'Dubrovnik', 'Split', 'Zadar', 'Rovinj', 'Pula', 'Hvar', 'Šibenik', 'Trogir'],
|
||||||
|
'Cuba': ['Havana', 'Varadero', 'Trinidad', 'Viñales', 'Santiago de Cuba', 'Cienfuegos'],
|
||||||
|
'Curaçao': ['Willemstad', 'Westpunt', 'Sint Willibrordus'],
|
||||||
|
'Cyprus': ['Nicosia', 'Limassol', 'Paphos', 'Larnaca', 'Ayia Napa'],
|
||||||
|
'Czechia': ['Prague', 'Brno', 'Český Krumlov', 'Karlovy Vary', 'Plzeň', 'Olomouc', 'Ostrava', 'Liberec'],
|
||||||
|
'Dem. Rep. Congo': ['Kinshasa', 'Lubumbashi', 'Goma', 'Bukavu', 'Kisangani'],
|
||||||
|
'Denmark': ['Copenhagen', 'Aarhus', 'Odense', 'Aalborg', 'Ribe', 'Skagen', 'Bornholm', 'Møns Klint'],
|
||||||
|
'Djibouti': ['Djibouti City', 'Tadjoura', 'Obock', 'Ali Sabieh'],
|
||||||
|
'Dominican Rep.': ['Santo Domingo', 'Punta Cana', 'Puerto Plata', 'La Romana', 'Samaná', 'Sosúa'],
|
||||||
|
'Ecuador': ['Quito', 'Guayaquil', 'Cuenca', 'Baños', 'Galápagos Islands', 'Otavalo', 'Montañita'],
|
||||||
|
'Egypt': ['Cairo', 'Alexandria', 'Luxor', 'Aswan', 'Hurghada', 'Sharm el-Sheikh', 'Giza', 'Dahab'],
|
||||||
|
'El Salvador': ['San Salvador', 'Santa Ana', 'San Miguel', 'La Libertad', 'Suchitoto'],
|
||||||
|
'Eq. Guinea': ['Malabo', 'Bata', 'Ebebiyín'],
|
||||||
|
'Eritrea': ['Asmara', 'Massawa', 'Keren', 'Assab'],
|
||||||
|
'Estonia': ['Tallinn', 'Tartu', 'Pärnu', 'Kuressaare', 'Narva'],
|
||||||
|
'Eswatini': ['Mbabane', 'Manzini', 'Big Bend', 'Mhlume'],
|
||||||
|
'Ethiopia': ['Addis Ababa', 'Lalibela', 'Gondar', 'Axum', 'Bahir Dar', 'Harar'],
|
||||||
|
'Faeroe Is.': ['Tórshavn', 'Klaksvík', 'Runavík', 'Vestmanna'],
|
||||||
|
'Fiji': ['Suva', 'Nadi', 'Lautoka', 'Denarau', 'Coral Coast'],
|
||||||
|
'Finland': ['Helsinki', 'Rovaniemi', 'Tampere', 'Turku', 'Levi', 'Savonlinna', 'Porvoo'],
|
||||||
|
'France': ['Paris', 'Nice', 'Marseille', 'Lyon', 'Bordeaux', 'Toulouse', 'Strasbourg', 'Lille', 'Montpellier', 'Avignon', 'Arles', 'Cannes', 'Saint-Tropez', 'Annecy', 'Chamonix', 'Biarritz', 'Colmar'],
|
||||||
|
'Gabon': ['Libreville', 'Port-Gentil', 'Franceville', 'Oyem'],
|
||||||
|
'Gambia': ['Banjul', 'Serrekunda', 'Brikama', 'Bakau'],
|
||||||
|
'Georgia': ['Tbilisi', 'Batumi', 'Kutaisi', 'Stepantsminda', 'Sighnaghi', 'Telavi', 'Mestia'],
|
||||||
|
'Germany': ['Berlin', 'Munich', 'Hamburg', 'Frankfurt', 'Cologne', 'Stuttgart', 'Düsseldorf', 'Dresden', 'Leipzig', 'Nuremberg', 'Heidelberg', 'Freiburg', 'Hannover', 'Bremen', 'Bonn', 'Rothenburg ob der Tauber', 'Neuschwanstein'],
|
||||||
|
'Ghana': ['Accra', 'Kumasi', 'Cape Coast', 'Tamale', 'Elmina', 'Takoradi'],
|
||||||
|
'Greece': ['Athens', 'Santorini', 'Mykonos', 'Crete', 'Thessaloniki', 'Corfu', 'Rhodes', 'Naxos', 'Paros', 'Milos', 'Delphi', 'Meteora', 'Olympia', 'Zakynthos'],
|
||||||
|
'Greenland': ['Nuuk', 'Ilulissat', 'Kangerlussuaq', 'Sisimiut'],
|
||||||
|
'Grenada': ['St. George\'s', 'Gouyave', 'Grenville', 'Sauteurs'],
|
||||||
|
'Guatemala': ['Guatemala City', 'Antigua', 'Lake Atitlán', 'Flores', 'Chichicastenango', 'Quetzaltenango', 'Semuc Champey'],
|
||||||
|
'Guinea': ['Conakry', 'Kindia', 'Kankan', 'N\'Zérékoré', 'Labé'],
|
||||||
|
'Guinea-Bissau': ['Bissau', 'Bafatá', 'Gabú', 'Cacheu'],
|
||||||
|
'Guyana': ['Georgetown', 'Linden', 'New Amsterdam', 'Bartica'],
|
||||||
|
'Haiti': ['Port-au-Prince', 'Cap-Haïtien', 'Jacmel', 'Les Cayes', 'Gonaïves'],
|
||||||
|
'Honduras': ['Tegucigalpa', 'San Pedro Sula', 'La Ceiba', 'Roatán', 'Copán Ruinas'],
|
||||||
|
'Hungary': ['Budapest', 'Debrecen', 'Szeged', 'Pécs', 'Eger', 'Siófok (Lake Balaton)', 'Visegrád', 'Hévíz'],
|
||||||
|
'Iceland': ['Reykjavík', 'Akureyri', 'Vík', 'Höfn', 'Ísafjörður', 'Blue Lagoon', 'Thingvellir'],
|
||||||
|
'India': ['Mumbai', 'Delhi', 'Jaipur', 'Agra', 'Varanasi', 'Goa', 'Kerala', 'Bangalore', 'Chennai', 'Kolkata', 'Hyderabad', 'Udaipur', 'Jaisalmer', 'Rishikesh', 'Darjeeling', 'Amritsar', 'Leh', 'Hampi', 'Mysore', 'Pondicherry'],
|
||||||
|
'Indonesia': ['Bali (Denpasar)', 'Jakarta', 'Yogyakarta', 'Lombok', 'Komodo', 'Surabaya', 'Bandung', 'Medan', 'Makassar', 'Labuan Bajo', 'Raja Ampat', 'Gili Islands'],
|
||||||
|
'Iran': ['Tehran', 'Isfahan', 'Shiraz', 'Mashhad', 'Tabriz', 'Yazd', 'Kashan'],
|
||||||
|
'Iraq': ['Baghdad', 'Erbil', 'Basra', 'Najaf', 'Karbala', 'Sulaymaniyah'],
|
||||||
|
'Ireland': ['Dublin', 'Galway', 'Cork', 'Killarney', 'Dingle', 'Cliffs of Moher', 'Kilkenny', 'Belfast (NI)', 'Ring of Kerry'],
|
||||||
|
'Israel': ['Tel Aviv', 'Jerusalem', 'Haifa', 'Eilat', 'Dead Sea', 'Nazareth', 'Tiberias', 'Caesarea', 'Akko'],
|
||||||
|
'Italy': ['Rome', 'Florence', 'Venice', 'Milan', 'Naples', 'Cinque Terre', 'Amalfi Coast', 'Positano', 'Capri', 'Verona', 'Bologna', 'Turin', 'Siena', 'Lake Como', 'Pisa', 'Palermo', 'Catania', 'Matera', 'Tuscany', 'Dolomites'],
|
||||||
|
'Jamaica': ['Kingston', 'Montego Bay', 'Negril', 'Ocho Rios', 'Port Antonio'],
|
||||||
|
'Japan': ['Tokyo', 'Osaka', 'Kyoto', 'Sapporo', 'Fukuoka', 'Hiroshima', 'Nara', 'Kanazawa', 'Nagoya', 'Yokohama', 'Kobe', 'Hakone', 'Nikko', 'Miyajima', 'Takayama', 'Okinawa', 'Kamakura', 'Fuji Five Lakes'],
|
||||||
|
'Jordan': ['Amman', 'Petra', 'Wadi Rum', 'Dead Sea', 'Aqaba', 'Madaba', 'Jerash'],
|
||||||
|
'Kazakhstan': ['Almaty', 'Nur-Sultan', 'Shymkent', 'Aktau', 'Karaganda'],
|
||||||
|
'Kenya': ['Nairobi', 'Mombasa', 'Masai Mara', 'Diani Beach', 'Amboseli', 'Lake Nakuru', 'Tsavo', 'Nanyuki'],
|
||||||
|
'Kiribati': ['Tarawa', 'Kiritimati (Christmas Island)'],
|
||||||
|
'Kosovo': ['Pristina', 'Prizren', 'Peja', 'Gjakova', 'Mitrovica'],
|
||||||
|
'Kuwait': ['Kuwait City', 'Salmiya', 'Hawally', 'Ahmadi', 'Jahra'],
|
||||||
|
'Kyrgyzstan': ['Bishkek', 'Osh', 'Karakol', 'Jalal-Abad', 'Talas'],
|
||||||
|
'Laos': ['Vientiane', 'Luang Prabang', 'Vang Vieng', 'Pakse', 'Savannakhet', 'Si Phan Don (4000 Islands)'],
|
||||||
|
'Latvia': ['Riga', 'Jūrmala', 'Liepāja', 'Cēsis', 'Sigulda', 'Daugavpils'],
|
||||||
|
'Lebanon': ['Beirut', 'Byblos', 'Baalbek', 'Tripoli', 'Sidon', 'Tyre', 'Jounieh'],
|
||||||
|
'Lesotho': ['Maseru', 'Teyateyaneng', 'Mafeteng', 'Hlotse'],
|
||||||
|
'Liberia': ['Monrovia', 'Buchanan', 'Ganta', 'Harper', 'Robertsport'],
|
||||||
|
'Libya': ['Tripoli', 'Benghazi', 'Misrata', 'Sabratha', 'Leptis Magna'],
|
||||||
|
'Liechtenstein': ['Vaduz', 'Schaan', 'Balzers', 'Triesenberg'],
|
||||||
|
'Lithuania': ['Vilnius', 'Kaunas', 'Klaipėda', 'Šiauliai', 'Trakai', 'Palanga', 'Nida'],
|
||||||
|
'Luxembourg': ['Luxembourg City', 'Echternach', 'Vianden', 'Ettelbruck'],
|
||||||
|
'Madagascar': ['Antananarivo', 'Nosy Be', 'Morondava', 'Fianarantsoa', 'Isalo', 'Tôlanaro'],
|
||||||
|
'Malawi': ['Lilongwe', 'Blantyre', 'Mzuzu', 'Lake Malawi', 'Zomba'],
|
||||||
|
'Malaysia': ['Kuala Lumpur', 'Penang (George Town)', 'Langkawi', 'Borneo (Kota Kinabalu)', 'Malacca', 'Cameron Highlands', 'Johor Bahru', 'Kuching', 'Sipadan Island'],
|
||||||
|
'Maldives': ['Malé', 'Ari Atoll', 'Baa Atoll', 'South Male Atoll', 'Addu City'],
|
||||||
|
'Mali': ['Bamako', 'Timbuktu', 'Ségou', 'Mopti', 'Djenné'],
|
||||||
|
'Malta': ['Valletta', 'Sliema', 'Gozo', 'Mellieħa', 'Mdina', 'St. Julian\'s', 'Comino'],
|
||||||
|
'Marshall Is.': ['Majuro', 'Kwajalein', 'Ebeye'],
|
||||||
|
'Mauritania': ['Nouakchott', 'Nouadhibou', 'Atar', 'Chinguetti', 'Ouadane'],
|
||||||
|
'Mauritius': ['Port Louis', 'Grand Baie', 'Flic en Flac', 'Belle Mare', 'Le Morne', 'Chamarel'],
|
||||||
|
'Mexico': ['Mexico City', 'Cancún', 'Playa del Carmen', 'Tulum', 'Guadalajara', 'Monterrey', 'Puerto Vallarta', 'Oaxaca', 'San Miguel de Allende', 'Mérida', 'Cabo San Lucas', 'Guanajuato', 'Chichen Itza', 'Palenque', 'Cuernavaca', 'Puebla'],
|
||||||
|
'Micronesia': ['Palikir', 'Chuuk', 'Pohnpei', 'Yap', 'Kosrae'],
|
||||||
|
'Moldova': ['Chișinău', 'Bălți', 'Tiraspol', 'Cahul', 'Orhei'],
|
||||||
|
'Monaco': ['Monaco City', 'Monte Carlo', 'La Condamine', 'Fontvieille'],
|
||||||
|
'Mongolia': ['Ulaanbaatar', 'Karakorum', 'Gobi Desert', 'Lake Khövsgöl', 'Altai', 'Erdenet'],
|
||||||
|
'Montenegro': ['Podgorica', 'Kotor', 'Budva', 'Bar', 'Ulcinj', 'Žabljak', 'Perast'],
|
||||||
|
'Morocco': ['Marrakech', 'Fes', 'Casablanca', 'Rabat', 'Tangier', 'Chefchaouen', 'Essaouira', 'Ouarzazate', 'Agadir', 'Meknes', 'Merzouga (Sahara)'],
|
||||||
|
'Mozambique': ['Maputo', 'Beira', 'Tofo (Inhambane)', 'Vilankulo', 'Bazaruto Archipelago', 'Nampula'],
|
||||||
|
'Myanmar': ['Yangon', 'Mandalay', 'Bagan', 'Inle Lake', 'Hpa-An', 'Ngapali Beach'],
|
||||||
|
'Namibia': ['Windhoek', 'Swakopmund', 'Sossusvlei', 'Etosha National Park', 'Fish River Canyon', 'Walvis Bay'],
|
||||||
|
'Nauru': ['Yaren', 'Boe', 'Aiwo'],
|
||||||
|
'Nepal': ['Kathmandu', 'Pokhara', 'Chitwan', 'Lumbini', 'Everest Base Camp', 'Nagarkot', 'Bandipur'],
|
||||||
|
'Netherlands': ['Amsterdam', 'Rotterdam', 'The Hague', 'Utrecht', 'Maastricht', 'Groningen', 'Leiden', 'Delft', 'Giethoorn', 'Haarlem', 'Zaanse Schans', 'Keukenhof'],
|
||||||
|
'New Zealand': ['Auckland', 'Queenstown', 'Wellington', 'Christchurch', 'Rotorua', 'Milford Sound', 'Wanaka', 'Taupō', 'Dunedin', 'Tongariro', 'Abel Tasman', 'Bay of Islands'],
|
||||||
|
'Nicaragua': ['Managua', 'Granada', 'León', 'San Juan del Sur', 'Ometepe Island', 'Corn Islands'],
|
||||||
|
'Niger': ['Niamey', 'Agadez', 'Zinder', 'Maradi', 'Tahoua'],
|
||||||
|
'Nigeria': ['Lagos', 'Abuja', 'Port Harcourt', 'Calabar', 'Ibadan', 'Kano', 'Enugu', 'Jos'],
|
||||||
|
'North Korea': ['Pyongyang', 'Kaesong', 'Chongjin', 'Nampo', 'Wonsan'],
|
||||||
|
'North Macedonia': ['Skopje', 'Ohrid', 'Bitola', 'Tetovo', 'Struga'],
|
||||||
|
'Norway': ['Oslo', 'Bergen', 'Tromsø', 'Stavanger', 'Trondheim', 'Lofoten Islands', 'Geirangerfjord', 'Flåm', 'Alesund', 'Preikestolen', 'Nordkapp'],
|
||||||
|
'Oman': ['Muscat', 'Salalah', 'Nizwa', 'Sur', 'Wahiba Sands', 'Khasab', 'Sohar'],
|
||||||
|
'Pakistan': ['Islamabad', 'Karachi', 'Lahore', 'Hunza Valley', 'Skardu', 'Faisalabad', 'Multan', 'Swat Valley'],
|
||||||
|
'Palau': ['Ngerulmud', 'Koror', 'Peleliu'],
|
||||||
|
'Palestine': ['Ramallah', 'Bethlehem', 'Hebron', 'Nablus', 'Jericho', 'Gaza'],
|
||||||
|
'Panama': ['Panama City', 'Bocas del Toro', 'Boquete', 'San Blas Islands', 'El Valle de Antón'],
|
||||||
|
'Papua New Guinea': ['Port Moresby', 'Lae', 'Mount Hagen', 'Kokopo', 'Alotau', 'Tufi'],
|
||||||
|
'Paraguay': ['Asunción', 'Ciudad del Este', 'Encarnación', 'San Bernardino', 'Filadelfia'],
|
||||||
|
'Peru': ['Lima', 'Cusco', 'Arequipa', 'Machu Picchu', 'Sacred Valley', 'Lake Titicaca', 'Iquitos (Amazon)', 'Paracas', 'Huaraz', 'Nazca', 'Máncora', 'Trujillo'],
|
||||||
|
'Philippines': ['Manila', 'Cebu', 'Palawan (El Nido)', 'Siargao', 'Boracay', 'Davao', 'Bohol (Panglao)', 'Banaue Rice Terraces', 'Coron', 'Baguio', 'Puerto Princesa'],
|
||||||
|
'Poland': ['Warsaw', 'Kraków', 'Gdańsk', 'Wrocław', 'Poznań', 'Zakopane', 'Gdynia', 'Łódź', 'Toruń', 'Szczecin', 'Lublin', 'Malbork', 'Morskie Oko'],
|
||||||
|
'Portugal': ['Lisbon', 'Porto', 'Algarve (Faro)', 'Sintra', 'Madeira', 'Coimbra', 'Azores', 'Braga', 'Évora', 'Cascais', 'Douro Valley'],
|
||||||
|
'Puerto Rico': ['San Juan', 'Ponce', 'Mayagüez', 'Culebra', 'Vieques', 'Rincón'],
|
||||||
|
'Qatar': ['Doha', 'Al Wakrah', 'Al Khor', 'Mesaieed', 'Katara'],
|
||||||
|
'Romania': ['Bucharest', 'Cluj-Napoca', 'Brașov', 'Sibiu', 'Sighișoara', 'Timișoara', 'Iași', 'Constanța', 'Transfăgărășan', 'Mamaia'],
|
||||||
|
'Russia': ['Moscow', 'Saint Petersburg', 'Moscow', 'Sochi', 'Vladivostok', 'Kazan', 'Novosibirsk', 'Yekaterinburg', 'Irkutsk', 'Lake Baikal', 'Murmansk', 'Kaliningrad', 'Kamchatka', 'Krasnodar', 'Nizhny Novgorod', 'Rostov-on-Don'],
|
||||||
|
'Rwanda': ['Kigali', 'Butare', 'Gisenyi', 'Volcanoes National Park', 'Akagera', 'Nyungwe Forest'],
|
||||||
|
'S. Sudan': ['Juba', 'Malakal', 'Wau', 'Bor', 'Yei'],
|
||||||
|
'Samoa': ['Apia', 'Salelologa', 'Lalomanu', 'Safua'],
|
||||||
|
'San Marino': ['San Marino City', 'Borgo Maggiore', 'Serravalle'],
|
||||||
|
'São Tomé and Principe': ['São Tomé', 'Santo António', 'Neves'],
|
||||||
|
'Saudi Arabia': ['Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'AlUla', 'Abha', 'Tabuk', 'Neom'],
|
||||||
|
'Senegal': ['Dakar', 'Saint-Louis', 'Gorée Island', 'Sine-Saloum Delta', 'Pink Lake (Lac Rose)', 'Cap Skirring'],
|
||||||
|
'Serbia': ['Belgrade', 'Novi Sad', 'Niš', 'Subotica', 'Kragujevac', 'Zlatibor', 'Kopaonik'],
|
||||||
|
'Seychelles': ['Mahé (Victoria)', 'Praslin', 'La Digue', 'Silhouette Island'],
|
||||||
|
'Sierra Leone': ['Freetown', 'Bo', 'Kenema', 'Makeni', 'Bunce Island'],
|
||||||
|
'Singapore': ['Singapore'],
|
||||||
|
'Slovakia': ['Bratislava', 'Košice', 'Tatras (High Tatras)', 'Banská Štiavnica', 'Levoča', 'Žilina', 'Poprad'],
|
||||||
|
'Slovenia': ['Ljubljana', 'Lake Bled', 'Piran', 'Maribor', 'Postojna Cave', 'Triglav National Park', 'Celje'],
|
||||||
|
'Solomon Is.': ['Honiara', 'Gizo', 'Auki', 'Munda'],
|
||||||
|
'Somalia': ['Mogadishu', 'Hargeisa', 'Kismayo', 'Baidoa', 'Berbera'],
|
||||||
|
'South Africa': ['Cape Town', 'Johannesburg', 'Durban', 'Kruger National Park', 'Garden Route', 'Cape Winelands (Stellenbosch)', 'Port Elizabeth', 'Hermanus', 'Blyde River Canyon', 'Drakensberg', 'Pretoria', 'Soweto', 'Knysna'],
|
||||||
|
'South Korea': ['Seoul', 'Busan', 'Jeju Island', 'Gyeongju', 'Incheon', 'Daegu', 'Daejeon', 'Gwangju', 'Jeonju', 'Seoraksan National Park', 'Andong', 'Suwon', 'Sokcho', 'Pyeongchang'],
|
||||||
|
'Spain': ['Barcelona', 'Madrid', 'Seville', 'Granada', 'Valencia', 'Bilbao', 'San Sebastián', 'Mallorca', 'Ibiza', 'Tenerife', 'Córdoba', 'Málaga', 'Santiago de Compostela', 'Toledo', 'Ronda', 'Salamanca', 'Marbella', 'Costa Brava', 'Alhambra', 'Picos de Europa'],
|
||||||
|
'Sri Lanka': ['Colombo', 'Kandy', 'Galle', 'Sigiriya', 'Ella', 'Mirissa', 'Anuradhapura', 'Polonnaruwa', 'Nuwara Eliya', 'Yala National Park'],
|
||||||
|
'St. Kitts and Nevis': ['Basseterre', 'Charlestown', 'Frigate Bay', 'Nevis'],
|
||||||
|
'St. Lucia': ['Castries', 'Soufrière', 'Gros Islet', 'Vieux Fort', 'Marigot Bay'],
|
||||||
|
'St. Pierre and Miquelon': ['Saint-Pierre', 'Miquelon'],
|
||||||
|
'St. Vin. and Gren.': ['Kingstown', 'Bequia', 'Mustique', 'Canouan', 'Union Island'],
|
||||||
|
'Sudan': ['Khartoum', 'Omdurman', 'Port Sudan', 'Kassala', 'Nyala'],
|
||||||
|
'Suriname': ['Paramaribo', 'Lelydorp', 'Brokopondo', 'Nieuw Nickerie'],
|
||||||
|
'Sweden': ['Stockholm', 'Gothenburg', 'Malmö', 'Kiruna', 'Visby', 'Uppsala', 'Lund', 'Abisko National Park', 'Icehotel (Jukkasjärvi)', 'Smögen'],
|
||||||
|
'Switzerland': ['Zurich', 'Geneva', 'Lucerne', 'Zermatt', 'Interlaken', 'Lugano', 'Lausanne', 'Bern', 'Grindelwald', 'St. Moritz', 'Jungfraujoch', 'Montreux', 'Matterhorn Glacier Paradise'],
|
||||||
|
'Syria': ['Damascus', 'Aleppo', 'Palmyra', 'Homs', 'Latakia', 'Maaloula'],
|
||||||
|
'Taiwan': ['Taipei', 'Taichung', 'Kaohsiung', 'Tainan', 'Taroko Gorge', 'Sun Moon Lake', 'Alishan', 'Jiufen', 'Kenting', 'Yilan', 'Taoyuan', 'Hualien'],
|
||||||
|
'Tajikistan': ['Dushanbe', 'Khujand', 'Pamir Mountains', 'Khorog', 'Bokhtar'],
|
||||||
|
'Tanzania': ['Dar es Salaam', 'Zanzibar City', 'Arusha', 'Serengeti National Park', 'Kilimanjaro', 'Ngorongoro Crater', 'Mwanza', 'Mbeya', 'Mafia Island', 'Lake Manyara', 'Selous'],
|
||||||
|
'Thailand': ['Bangkok', 'Chiang Mai', 'Phuket', 'Krabi', 'Pattaya', 'Koh Samui', 'Koh Phi Phi', 'Koh Tao', 'Ayutthaya', 'Chiang Rai', 'Hua Hin', 'Khao Sok', 'Pai', 'Kanchanaburi', 'Sukhothai', 'Koh Lanta', 'Railay Beach', 'Erawan National Park'],
|
||||||
|
'Timor-Leste': ['Dili', 'Baucau', 'Same', 'Atauro Island', 'Jaco Island'],
|
||||||
|
'Togo': ['Lomé', 'Kpalimé', 'Sokodé', 'Kara', 'Aneho'],
|
||||||
|
'Tonga': ['Nuku\'alofa', 'Neiafu', 'Pangai', 'Ha\'apai', 'Eua'],
|
||||||
|
'Trinidad and Tobago': ['Port of Spain', 'San Fernando', 'Tobago (Scarborough)', 'Chaguanas', 'Maracas Bay'],
|
||||||
|
'Tunisia': ['Tunis', 'Sousse', 'Hammamet', 'Djerba', 'Sfax', 'Carthage', 'Douz (Sahara)', 'Matmata', 'Bizerte', 'El Jem'],
|
||||||
|
'Turkey': ['Istanbul', 'Cappadocia (Göreme)', 'Antalya', 'Izmir', 'Bodrum', 'Fethiye', 'Pamukkale', 'Ephesus', 'Marmaris', 'Alanya', 'Ankara', 'Trabzon', 'Kas', 'Olympos', 'Gallipoli', 'Konya', 'Mardin', 'Butterfly Valley'],
|
||||||
|
'Turkmenistan': ['Ashgabat', 'Mary', 'Turkmenbashi', 'Dashoguz', 'Köneürgenç'],
|
||||||
|
'Tuvalu': ['Funafuti', 'Nanumea', 'Nukulaelae'],
|
||||||
|
'U.S. Virgin Is.': ['Charlotte Amalie', 'Christiansted', 'Frederiksted', 'Cruz Bay'],
|
||||||
|
'Uganda': ['Kampala', 'Jinja', 'Murchison Falls', 'Queen Elizabeth National Park', 'Bwindi Impenetrable Forest', 'Kibale', 'Lake Bunyonyi', 'Entebbe'],
|
||||||
|
'Ukraine': ['Kyiv', 'Lviv', 'Odesa', 'Kharkiv', 'Carpathian Mountains', 'Dnipro', 'Chernivtsi', 'Kamianets-Podilskyi', 'Zaporizhzhia', 'Lutsk'],
|
||||||
|
'United Arab Emirates': ['Dubai', 'Abu Dhabi', 'Sharjah', 'Fujairah', 'Ras Al Khaimah', 'Ajman', 'Hatta'],
|
||||||
|
'United Kingdom': ['London', 'Edinburgh', 'Bath', 'York', 'Liverpool', 'Manchester', 'Birmingham', 'Cambridge', 'Oxford', 'Brighton', 'Cornwall', 'Bristol', 'Cardiff', 'Glasgow', 'Inverness', 'Belfast', 'Lake District', 'Scottish Highlands', 'St. Ives', 'Canterbury', 'Dover', 'Stratford-upon-Avon'],
|
||||||
|
'United States of America': ['New York', 'Los Angeles', 'Chicago', 'San Francisco', 'Las Vegas', 'Miami', 'Orlando', 'Washington DC', 'Boston', 'Seattle', 'Portland', 'Denver', 'New Orleans', 'Nashville', 'Austin', 'San Diego', 'Honolulu', 'Grand Canyon', 'Yellowstone', 'Yosemite', 'Houston', 'Dallas', 'Atlanta', 'Philadelphia', 'Phoenix', 'San Antonio', 'Savannah', 'Charleston', 'Santa Fe', 'Anchorage', 'Maui', 'Kauai', 'Moab', 'Portland (ME)', 'Asheville', 'Sedona', 'Napa Valley'],
|
||||||
|
'Uruguay': ['Montevideo', 'Punta del Este', 'Colonia del Sacramento', 'Piriapolis', 'Cabo Polonio', 'Rocha'],
|
||||||
|
'Uzbekistan': ['Tashkent', 'Samarkand', 'Bukhara', 'Khiva', 'Shakhrisabz', 'Fergana', 'Nukus', 'Termez'],
|
||||||
|
'Vanuatu': ['Port Vila', 'Luganville', 'Tanna Island', 'Pentecost', 'Espiritu Santo'],
|
||||||
|
'Vatican': ['Vatican City'],
|
||||||
|
'Venezuela': ['Caracas', 'Angel Falls (Canaima)', 'Margarita Island', 'Los Roques', 'Mérida', 'Roraima', 'Maracaibo', 'Valencia'],
|
||||||
|
'Vietnam': ['Hanoi', 'Ho Chi Minh City (Saigon)', 'Ha Long Bay', 'Hoi An', 'Da Nang', 'Hue', 'Nha Trang', 'Phong Nha', 'Da Lat', 'Sapa', 'Phu Quoc', 'Mui Ne', 'Con Dao', 'Mekong Delta (Can Tho)', 'Ninh Binh', 'Son Doong Cave'],
|
||||||
|
'W. Sahara': ['Laayoune', 'Dakhla', 'Smara', 'Boujdour'],
|
||||||
|
'Yemen': ['Sana\'a', 'Aden', 'Socotra', 'Taiz', 'Mukalla', 'Shibam'],
|
||||||
|
'Zambia': ['Lusaka', 'Victoria Falls', 'Livingstone', 'South Luangwa National Park', 'Kitwe', 'Ndola', 'Kasama'],
|
||||||
|
'Zimbabwe': ['Harare', 'Victoria Falls', 'Bulawayo', 'Hwange National Park', 'Mutare', 'Gweru', 'Masvingo (Great Zimbabwe)'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get curated city suggestions for a given country name.
|
||||||
|
* @param {string} countryName
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getCitiesForCountry(countryName) {
|
||||||
|
return ALL_CITIES[countryName] || [];
|
||||||
|
}
|
||||||
@@ -1,25 +1,84 @@
|
|||||||
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();
|
const _features = feature(worldData, worldData.objects.countries).features;
|
||||||
|
|
||||||
|
export const countryNames = _features
|
||||||
|
.map(f => f.properties?.name).filter(Boolean).sort();
|
||||||
|
|
||||||
|
// country name → topojson numeric ID (e.g. 'Japan' → '392')
|
||||||
|
export const nameToId = Object.fromEntries(
|
||||||
|
_features
|
||||||
|
.filter(f => f.properties?.name && f.id)
|
||||||
|
.map(f => [f.properties.name, String(f.id)])
|
||||||
|
);
|
||||||
|
nameToId['Kosovo'] = 'XK';
|
||||||
|
|
||||||
/** @param {string} country */
|
/** @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('');
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/lib/shared/countryCities.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
export const countryCities = {
|
||||||
|
'Afghanistan': ['Kabul','Kandahar','Herat','Mazar-i-Sharif','Jalalabad'],
|
||||||
|
'Albania': ['Tirana','Durrës','Vlorë','Shkodër','Elbasan'],
|
||||||
|
'Algeria': ['Algiers','Oran','Constantine','Annaba','Blida'],
|
||||||
|
'Andorra': ['Andorra la Vella','Escaldes-Engordany','Encamp'],
|
||||||
|
'Angola': ['Luanda','Huambo','Lobito','Benguela','Lubango'],
|
||||||
|
'Argentina': ['Buenos Aires','Córdoba','Rosario','Mendoza','Bariloche','Salta','Mar del Plata'],
|
||||||
|
'Armenia': ['Yerevan','Gyumri','Vanadzor'],
|
||||||
|
'Australia': ['Sydney','Melbourne','Brisbane','Perth','Adelaide','Gold Coast','Cairns','Darwin','Hobart','Canberra'],
|
||||||
|
'Austria': ['Vienna','Salzburg','Graz','Innsbruck','Linz','Hallstatt'],
|
||||||
|
'Azerbaijan': ['Baku','Ganja','Sumqayit'],
|
||||||
|
'Bahamas': ['Nassau','Freeport'],
|
||||||
|
'Bahrain': ['Manama','Riffa','Muharraq'],
|
||||||
|
'Bangladesh': ['Dhaka','Chittagong','Sylhet','Rajshahi','Khulna'],
|
||||||
|
'Barbados': ['Bridgetown'],
|
||||||
|
'Belarus': ['Minsk','Gomel','Brest','Grodno'],
|
||||||
|
'Belgium': ['Brussels','Bruges','Ghent','Antwerp','Liège'],
|
||||||
|
'Belize': ['Belize City','San Ignacio','Placencia'],
|
||||||
|
'Benin': ['Cotonou','Porto-Novo','Abomey'],
|
||||||
|
'Bhutan': ['Thimphu','Paro','Punakha'],
|
||||||
|
'Bolivia': ['La Paz','Santa Cruz','Cochabamba','Sucre','Uyuni'],
|
||||||
|
'Bosnia and Herz.': ['Sarajevo','Mostar','Banja Luka'],
|
||||||
|
'Botswana': ['Gaborone','Francistown','Maun'],
|
||||||
|
'Brazil': ['São Paulo','Rio de Janeiro','Brasília','Salvador','Fortaleza','Manaus','Recife','Florianópolis','Foz do Iguaçu'],
|
||||||
|
'Brunei': ['Bandar Seri Begawan'],
|
||||||
|
'Bulgaria': ['Sofia','Plovdiv','Varna','Burgas','Ruse'],
|
||||||
|
'Burkina Faso': ['Ouagadougou','Bobo-Dioulasso'],
|
||||||
|
'Burundi': ['Bujumbura','Gitega'],
|
||||||
|
'Cabo Verde': ['Praia','Mindelo'],
|
||||||
|
'Cambodia': ['Phnom Penh','Siem Reap','Sihanoukville','Battambang'],
|
||||||
|
'Cameroon': ['Yaoundé','Douala','Bafoussam'],
|
||||||
|
'Canada': ['Toronto','Vancouver','Montreal','Calgary','Ottawa','Edmonton','Quebec City','Whistler','Banff','Niagara Falls','Halifax'],
|
||||||
|
'Central African Rep.': ['Bangui'],
|
||||||
|
'Chad': ["N'Djamena",'Moundou'],
|
||||||
|
'Chile': ['Santiago','Valparaíso','Atacama','Puerto Natales','Punta Arenas','Viña del Mar','San Pedro de Atacama'],
|
||||||
|
'China': ['Beijing','Shanghai','Guangzhou','Shenzhen','Chengdu','Xi\'an','Hangzhou','Chongqing','Guilin','Zhangjiajie','Lijiang','Hong Kong','Macau'],
|
||||||
|
'Colombia': ['Bogotá','Medellín','Cartagena','Cali','Santa Marta','Barranquilla'],
|
||||||
|
'Comoros': ['Moroni'],
|
||||||
|
'Congo': ['Brazzaville','Pointe-Noire'],
|
||||||
|
'Costa Rica': ['San José','Manuel Antonio','Tamarindo','Arenal','Monteverde'],
|
||||||
|
'Croatia': ['Zagreb','Dubrovnik','Split','Hvar','Zadar','Pula','Rijeka'],
|
||||||
|
'Cuba': ['Havana','Trinidad','Varadero','Santiago de Cuba','Cienfuegos'],
|
||||||
|
'Cyprus': ['Nicosia','Limassol','Paphos','Larnaca','Ayia Napa'],
|
||||||
|
'Czechia': ['Prague','Brno','Český Krumlov','Karlovy Vary','Olomouc'],
|
||||||
|
"Côte d'Ivoire": ['Abidjan','Yamoussoukro','Bouaké'],
|
||||||
|
'Dem. Rep. Congo': ['Kinshasa','Lubumbashi','Goma','Kisangani'],
|
||||||
|
'Denmark': ['Copenhagen','Aarhus','Odense','Aalborg'],
|
||||||
|
'Djibouti': ['Djibouti City'],
|
||||||
|
'Dominican Rep.': ['Santo Domingo','Punta Cana','Santiago','La Romana'],
|
||||||
|
'Ecuador': ['Quito','Guayaquil','Cuenca','Baños','Galápagos Islands'],
|
||||||
|
'Egypt': ['Cairo','Alexandria','Luxor','Aswan','Sharm el-Sheikh','Hurghada','Giza'],
|
||||||
|
'El Salvador': ['San Salvador','Santa Ana','San Miguel'],
|
||||||
|
'Eq. Guinea': ['Malabo','Bata'],
|
||||||
|
'Eritrea': ['Asmara','Massawa'],
|
||||||
|
'Estonia': ['Tallinn','Tartu','Pärnu'],
|
||||||
|
'Ethiopia': ['Addis Ababa','Lalibela','Gondar','Axum','Dire Dawa'],
|
||||||
|
'Fiji': ['Suva','Nadi','Mamanuca Islands'],
|
||||||
|
'Finland': ['Helsinki','Rovaniemi','Tampere','Turku','Oulu'],
|
||||||
|
'Fr. Polynesia': ['Papeete','Bora Bora','Moorea'],
|
||||||
|
'France': ['Paris','Nice','Lyon','Marseille','Bordeaux','Strasbourg','Toulouse','Cannes','Monaco','Mont Saint-Michel','Versailles'],
|
||||||
|
'Gabon': ['Libreville','Port-Gentil'],
|
||||||
|
'Gambia': ['Banjul','Serekunda'],
|
||||||
|
'Georgia': ['Tbilisi','Batumi','Kutaisi','Sighnaghi'],
|
||||||
|
'Germany': ['Berlin','Munich','Hamburg','Frankfurt','Cologne','Dresden','Heidelberg','Rothenburg ob der Tauber','Neuschwanstein','Stuttgart'],
|
||||||
|
'Ghana': ['Accra','Kumasi','Cape Coast','Tamale'],
|
||||||
|
'Greece': ['Athens','Santorini','Mykonos','Rhodes','Thessaloniki','Crete','Corfu','Meteora'],
|
||||||
|
'Greenland': ['Nuuk','Ilulissat'],
|
||||||
|
'Grenada': ["St. George's"],
|
||||||
|
'Guatemala': ['Guatemala City','Antigua','Lake Atitlán','Tikal','Quetzaltenango'],
|
||||||
|
'Guinea': ['Conakry'],
|
||||||
|
'Guyana': ['Georgetown'],
|
||||||
|
'Haiti': ['Port-au-Prince','Cap-Haïtien'],
|
||||||
|
'Honduras': ['Tegucigalpa','San Pedro Sula','Roatán'],
|
||||||
|
'Hong Kong': ['Hong Kong'],
|
||||||
|
'Hungary': ['Budapest','Debrecen','Pécs','Eger','Győr'],
|
||||||
|
'Iceland': ['Reykjavik','Akureyri','Blue Lagoon','Golden Circle'],
|
||||||
|
'India': ['Mumbai','Delhi','Jaipur','Agra','Bangalore','Chennai','Kolkata','Goa','Varanasi','Udaipur','Kerala','Leh','Shimla'],
|
||||||
|
'Indonesia': ['Jakarta','Bali','Yogyakarta','Lombok','Medan','Komodo','Raja Ampat','Surabaya'],
|
||||||
|
'Iran': ['Tehran','Isfahan','Shiraz','Persepolis','Yazd'],
|
||||||
|
'Iraq': ['Baghdad','Erbil','Basra','Najaf'],
|
||||||
|
'Ireland': ['Dublin','Cork','Galway','Killarney','Limerick'],
|
||||||
|
'Israel': ['Jerusalem','Tel Aviv','Haifa','Eilat','Dead Sea'],
|
||||||
|
'Italy': ['Rome','Florence','Venice','Milan','Naples','Amalfi','Sicily','Cinque Terre','Bologna','Turin'],
|
||||||
|
'Jamaica': ['Kingston','Montego Bay','Negril','Ocho Rios'],
|
||||||
|
'Japan': ['Tokyo','Kyoto','Osaka','Hiroshima','Nara','Sapporo','Hakone','Nikko','Kanazawa','Okinawa','Fukuoka'],
|
||||||
|
'Jordan': ['Amman','Petra','Wadi Rum','Aqaba','Jerash'],
|
||||||
|
'Kazakhstan': ['Almaty','Nur-Sultan','Shymkent'],
|
||||||
|
'Kenya': ['Nairobi','Mombasa','Masai Mara','Amboseli','Zanzibar'],
|
||||||
|
'Kosovo': ['Pristina','Prizren'],
|
||||||
|
'Kuwait': ['Kuwait City'],
|
||||||
|
'Kyrgyzstan': ['Bishkek','Osh','Karakol'],
|
||||||
|
'Laos': ['Vientiane','Luang Prabang','Vang Vieng'],
|
||||||
|
'Latvia': ['Riga','Jūrmala','Sigulda'],
|
||||||
|
'Lebanon': ['Beirut','Byblos','Baalbek','Sidon'],
|
||||||
|
'Libya': ['Tripoli','Benghazi','Leptis Magna'],
|
||||||
|
'Liechtenstein': ['Vaduz'],
|
||||||
|
'Lithuania': ['Vilnius','Kaunas','Trakai','Klaipėda'],
|
||||||
|
'Luxembourg': ['Luxembourg City','Vianden'],
|
||||||
|
'Madagascar': ['Antananarivo','Nosy Be','Morondava'],
|
||||||
|
'Malawi': ['Lilongwe','Blantyre','Lake Malawi'],
|
||||||
|
'Malaysia': ['Kuala Lumpur','Penang','Langkawi','Kota Kinabalu','Malacca','George Town'],
|
||||||
|
'Maldives': ['Malé','Maafushi'],
|
||||||
|
'Mali': ['Bamako','Timbuktu','Djenné'],
|
||||||
|
'Malta': ['Valletta','Mdina','Gozo'],
|
||||||
|
'Mauritania': ['Nouakchott'],
|
||||||
|
'Mauritius': ['Port Louis','Grand Baie','Flic en Flac'],
|
||||||
|
'Mexico': ['Mexico City','Cancún','Guadalajara','Oaxaca','Tulum','Playa del Carmen','San Miguel de Allende','Monterrey','Chichen Itza'],
|
||||||
|
'Moldova': ['Chișinău'],
|
||||||
|
'Monaco': ['Monaco'],
|
||||||
|
'Mongolia': ['Ulaanbaatar','Gobi Desert'],
|
||||||
|
'Montenegro': ['Podgorica','Kotor','Budva','Bar'],
|
||||||
|
'Morocco': ['Marrakech','Fes','Casablanca','Rabat','Chefchaouen','Essaouira','Sahara Desert'],
|
||||||
|
'Mozambique': ['Maputo','Beira','Pemba'],
|
||||||
|
'Myanmar': ['Yangon','Bagan','Mandalay','Inle Lake'],
|
||||||
|
'Namibia': ['Windhoek','Swakopmund','Etosha','Sossusvlei'],
|
||||||
|
'Nepal': ['Kathmandu','Pokhara','Everest Base Camp','Chitwan','Lumbini'],
|
||||||
|
'Netherlands': ['Amsterdam','Rotterdam','The Hague','Utrecht','Delft','Eindhoven'],
|
||||||
|
'New Zealand': ['Auckland','Queenstown','Wellington','Christchurch','Rotorua','Milford Sound'],
|
||||||
|
'Nicaragua': ['Managua','Granada','León'],
|
||||||
|
'Niger': ['Niamey','Agadez'],
|
||||||
|
'Nigeria': ['Lagos','Abuja','Kano','Ibadan'],
|
||||||
|
'North Korea': ['Pyongyang'],
|
||||||
|
'North Macedonia': ['Skopje','Ohrid'],
|
||||||
|
'Norway': ['Oslo','Bergen','Tromsø','Flåm','Ålesund','Stavanger'],
|
||||||
|
'Oman': ['Muscat','Nizwa','Salalah','Wahiba Sands'],
|
||||||
|
'Pakistan': ['Karachi','Lahore','Islamabad','Peshawar','Gilgit'],
|
||||||
|
'Palestine': ['Ramallah','Bethlehem','Jericho','Hebron'],
|
||||||
|
'Panama': ['Panama City','Bocas del Toro','Boquete'],
|
||||||
|
'Papua New Guinea': ['Port Moresby'],
|
||||||
|
'Paraguay': ['Asunción','Ciudad del Este'],
|
||||||
|
'Peru': ['Lima','Cusco','Machu Picchu','Arequipa','Puno','Iquitos'],
|
||||||
|
'Philippines': ['Manila','Cebu','Palawan','Boracay','Davao','Siargao'],
|
||||||
|
'Poland': ['Warsaw','Kraków','Gdańsk','Wrocław','Poznań','Zakopane'],
|
||||||
|
'Portugal': ['Lisbon','Porto','Algarve','Sintra','Madeira','Azores','Évora'],
|
||||||
|
'Puerto Rico': ['San Juan','Ponce','Rincon'],
|
||||||
|
'Qatar': ['Doha'],
|
||||||
|
'Romania': ['Bucharest','Transylvania','Cluj-Napoca','Sibiu','Brașov','Sinaia'],
|
||||||
|
'Russia': ['Moscow','St. Petersburg','Irkutsk','Vladivostok','Sochi','Kazan','Novosibirsk'],
|
||||||
|
'Rwanda': ['Kigali','Volcanoes National Park'],
|
||||||
|
'S. Sudan': ['Juba'],
|
||||||
|
'Saint Lucia': ['Castries','Soufrière'],
|
||||||
|
'Saudi Arabia': ['Riyadh','Jeddah','Mecca','Medina','AlUla','NEOM'],
|
||||||
|
'Senegal': ['Dakar','Saint-Louis','Ziguinchor'],
|
||||||
|
'Serbia': ['Belgrade','Novi Sad','Niš'],
|
||||||
|
'Seychelles': ['Victoria','La Digue','Praslin','Mahé'],
|
||||||
|
'Sierra Leone': ['Freetown'],
|
||||||
|
'Singapore': ['Singapore'],
|
||||||
|
'Slovakia': ['Bratislava','Košice','Banská Bystrica'],
|
||||||
|
'Slovenia': ['Ljubljana','Bled','Piran','Maribor'],
|
||||||
|
'Solomon Is.': ['Honiara'],
|
||||||
|
'Somalia': ['Mogadishu'],
|
||||||
|
'South Africa': ['Cape Town','Johannesburg','Durban','Stellenbosch','Kruger','Garden Route','Pretoria'],
|
||||||
|
'South Korea': ['Seoul','Busan','Jeju','Gyeongju','Incheon','Suwon'],
|
||||||
|
'Spain': ['Barcelona','Madrid','Seville','Granada','Valencia','Bilbao','Toledo','San Sebastián','Ibiza','Mallorca'],
|
||||||
|
'Sri Lanka': ['Colombo','Kandy','Galle','Ella','Sigiriya','Mirissa'],
|
||||||
|
'Sudan': ['Khartoum','Omdurman'],
|
||||||
|
'Suriname': ['Paramaribo'],
|
||||||
|
'Sweden': ['Stockholm','Gothenburg','Malmö','Uppsala','Kiruna'],
|
||||||
|
'Switzerland': ['Zurich','Geneva','Bern','Interlaken','Lucerne','Zermatt','Lugano','Grindelwald'],
|
||||||
|
'Syria': ['Damascus','Aleppo','Palmyra'],
|
||||||
|
'São Tomé and Príncipe': ['São Tomé'],
|
||||||
|
'Taiwan': ['Taipei','Kaohsiung','Tainan','Taichung'],
|
||||||
|
'Tajikistan': ['Dushanbe','Khujand'],
|
||||||
|
'Tanzania': ['Dar es Salaam','Zanzibar','Serengeti','Arusha','Kilimanjaro'],
|
||||||
|
'Thailand': ['Bangkok','Chiang Mai','Phuket','Koh Samui','Koh Phi Phi','Ayutthaya','Pai','Krabi'],
|
||||||
|
'Timor-Leste': ['Dili'],
|
||||||
|
'Togo': ['Lomé'],
|
||||||
|
'Trinidad and Tobago': ['Port of Spain'],
|
||||||
|
'Tunisia': ['Tunis','Carthage','Sousse','Hammamet','Djerba'],
|
||||||
|
'Turkey': ['Istanbul','Cappadocia','Antalya','Bodrum','Ankara','Ephesus','Pamukkale','Trabzon'],
|
||||||
|
'Turkmenistan': ['Ashgabat','Merv'],
|
||||||
|
'Uganda': ['Kampala','Bwindi','Jinja'],
|
||||||
|
'Ukraine': ['Kyiv','Lviv','Odessa','Kharkiv'],
|
||||||
|
'United Arab Emirates': ['Dubai','Abu Dhabi','Sharjah'],
|
||||||
|
'United Kingdom': ['London','Edinburgh','Manchester','Liverpool','Oxford','Cambridge','Bath','York','Brighton','Glasgow','Dublin'],
|
||||||
|
'United States of America': ['New York','Los Angeles','Chicago','Miami','San Francisco','Las Vegas','New Orleans','Seattle','Boston','Washington D.C.','Nashville','Denver','Honolulu','Anchorage','Portland'],
|
||||||
|
'Uruguay': ['Montevideo','Punta del Este','Colonia del Sacramento'],
|
||||||
|
'Uzbekistan': ['Tashkent','Samarkand','Bukhara','Khiva'],
|
||||||
|
'Venezuela': ['Caracas','Medellín','Canaima','Los Roques'],
|
||||||
|
'Vietnam': ['Hanoi','Ho Chi Minh City','Hoi An','Da Nang','Ha Long Bay','Hue','Sapa','Phu Quoc'],
|
||||||
|
'Yemen': ["Sana'a",'Aden'],
|
||||||
|
'Zambia': ['Lusaka','Livingstone','Victoria Falls'],
|
||||||
|
'Zimbabwe': ['Harare','Bulawayo','Victoria Falls'],
|
||||||
|
};
|
||||||
15
src/lib/shared/types.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* id: string,
|
||||||
|
* title: string,
|
||||||
|
* date: string,
|
||||||
|
* location: { country: string, cities: string[] },
|
||||||
|
* photos: string[],
|
||||||
|
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
|
||||||
|
* tripType: 'solo' | 'friends' | 'family',
|
||||||
|
* days: number,
|
||||||
|
* memo: string
|
||||||
|
* }} JournalEntry
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
50
src/lib/stores/entriesStore.svelte.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -1,123 +1,2 @@
|
|||||||
import { writable } from 'svelte/store';
|
export { journals } from './entriesStore.svelte.js';
|
||||||
|
export { addEntry as addJournal } from './entriesStore.svelte.js';
|
||||||
/**
|
|
||||||
* @typedef {{
|
|
||||||
* id: string,
|
|
||||||
* title: string,
|
|
||||||
* date: string,
|
|
||||||
* location: { country: string, city: string },
|
|
||||||
* photos: string[],
|
|
||||||
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
|
|
||||||
* tripType: 'solo' | 'friends',
|
|
||||||
* days: number,
|
|
||||||
* memo: string
|
|
||||||
* }} JournalEntry
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {JournalEntry[]} */
|
|
||||||
const mockEntries = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'First Day in Tokyo',
|
|
||||||
date: '2024-03-15',
|
|
||||||
location: { country: 'Japan', city: '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', city: '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', city: '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', city: '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', city: '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', city: '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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { journals, addJournal, updateJournal } from '../stores/journalStore.js';
|
|
||||||
import { countryNames } from '../shared/countries.js';
|
|
||||||
import SearchInput from '../shared/SearchInput.svelte';
|
|
||||||
import PhotoEditor from './PhotoEditor.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* entry = null → "new entry" mode
|
|
||||||
* entry = {...} → "edit" mode
|
|
||||||
* @type {{ entry?: import('../stores/journalStore.js').JournalEntry | null, initialCountry?: string, onBack: () => void }}
|
|
||||||
*/
|
|
||||||
let { entry = null, initialCountry = '', onBack } = $props();
|
|
||||||
|
|
||||||
let isNew = !entry;
|
|
||||||
|
|
||||||
let city = $state(entry?.location.city ?? '');
|
|
||||||
let country = $state(entry?.location.country ?? initialCountry);
|
|
||||||
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
|
||||||
let days = $state(String(entry?.days ?? 1));
|
|
||||||
let tripType = $state(entry?.tripType ?? 'solo');
|
|
||||||
let photos = $state([...(entry?.photos ?? [])]);
|
|
||||||
let memo = $state(entry?.memo ?? '');
|
|
||||||
let transport = $state(entry?.transport ?? 'flight');
|
|
||||||
|
|
||||||
const transportOptions = [
|
|
||||||
{ value: 'flight', label: '✈ Flight' },
|
|
||||||
{ value: 'train', label: '🚂 Train' },
|
|
||||||
{ value: 'bus', label: '🚌 Bus' },
|
|
||||||
{ value: 'car', label: '🚗 Car' },
|
|
||||||
{ value: 'ship', label: '🚢 Ship' },
|
|
||||||
{ value: 'walk', label: '🚶 Walk' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MEMO_MAX = 100;
|
|
||||||
let wordCount = $derived(memo.trim() === '' ? 0 : memo.trim().split(/\s+/).length);
|
|
||||||
let memoOverLimit = $derived(wordCount > MEMO_MAX);
|
|
||||||
|
|
||||||
function onMemoInput(e) {
|
|
||||||
const raw = e.currentTarget.value;
|
|
||||||
const words = raw.trim() === '' ? [] : raw.trim().split(/\s+/);
|
|
||||||
if (words.length > MEMO_MAX) {
|
|
||||||
// keep first 100 words, preserve trailing space if user is mid-word
|
|
||||||
memo = words.slice(0, MEMO_MAX).join(' ');
|
|
||||||
e.currentTarget.value = memo;
|
|
||||||
} else {
|
|
||||||
memo = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cityOptions = $derived(
|
|
||||||
[...new Set(get(journals).map(e => e.location.city))].sort()
|
|
||||||
);
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
if (isNew) {
|
|
||||||
addJournal({
|
|
||||||
title: `${city}, ${country}`,
|
|
||||||
date,
|
|
||||||
days: Number(days),
|
|
||||||
tripType,
|
|
||||||
memo,
|
|
||||||
photos,
|
|
||||||
transport,
|
|
||||||
location: { city, country },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateJournal({
|
|
||||||
...entry,
|
|
||||||
date,
|
|
||||||
days: Number(days),
|
|
||||||
tripType,
|
|
||||||
transport,
|
|
||||||
memo,
|
|
||||||
photos,
|
|
||||||
location: { city, country },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onBack();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="edit-layout">
|
|
||||||
|
|
||||||
<header class="edit-topbar">
|
|
||||||
<div class="topbar-left">
|
|
||||||
<button class="topbar-btn" onclick={onBack}>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
|
||||||
</svg>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span class="topbar-title">{isNew ? 'New entry' : 'Edit'}</span>
|
|
||||||
<div class="topbar-right">
|
|
||||||
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="edit-scroll">
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="edit-country">Country <span class="req">*</span></label>
|
|
||||||
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="edit-city">City <span class="req">*</span></label>
|
|
||||||
<SearchInput id="edit-city" bind:value={city} options={cityOptions} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="edit-date">Date <span class="req">*</span></label>
|
|
||||||
<input id="edit-date" class="input" type="date" bind:value={date} required />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="edit-days">Days <span class="req">*</span></label>
|
|
||||||
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Trip type</label>
|
|
||||||
<div class="toggle-row">
|
|
||||||
<label class="toggle-opt" class:active={tripType === 'solo'}>
|
|
||||||
<input type="radio" name="tripType" value="solo" bind:group={tripType} /> Solo
|
|
||||||
</label>
|
|
||||||
<label class="toggle-opt" class:active={tripType === 'friends'}>
|
|
||||||
<input type="radio" name="tripType" value="friends" bind:group={tripType} /> With friends
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">How did you get there?</label>
|
|
||||||
<div class="transport-grid">
|
|
||||||
{#each transportOptions as opt}
|
|
||||||
<label class="transport-opt" class:active={transport === opt.value}>
|
|
||||||
<input type="radio" name="transport" value={opt.value} bind:group={transport} />
|
|
||||||
{opt.label}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="label-row">
|
|
||||||
<label class="label" for="edit-memo">How was it?</label>
|
|
||||||
<span class="char-count" class:over={memoOverLimit}>{wordCount} / {MEMO_MAX} words</span>
|
|
||||||
</div>
|
|
||||||
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="4" value={memo} oninput={onMemoInput}></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.edit-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 20px;
|
|
||||||
height: 52px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-left, .topbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
.topbar-right { justify-content: flex-end; }
|
|
||||||
|
|
||||||
.topbar-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.topbar-btn:hover {
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
.topbar-btn--save {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.topbar-btn--save:hover {
|
|
||||||
background: var(--accent-dark);
|
|
||||||
border-color: var(--accent-dark);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-scroll {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 36px 48px 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-sub);
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
.char-count.over { color: #dc2626; }
|
|
||||||
.input-over { border-color: #fca5a5; }
|
|
||||||
|
|
||||||
.req {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-h);
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.input:focus { border-color: var(--accent-border); }
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
resize: vertical;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-opt {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 7px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
}
|
|
||||||
.toggle-opt input { display: none; }
|
|
||||||
.toggle-opt.active {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transport-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.transport-opt {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.transport-opt input { display: none; }
|
|
||||||
.transport-opt.active {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
<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.city}, {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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-cancel" onclick={onCancel}>Cancel</button>
|
<button class="btn btn-cancel" onclick={onCancel}>Cancel</button>
|
||||||
429
src/lib/timeline/detail/EditForm.svelte
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
<script>
|
||||||
|
import { getEntries } from '../../stores/entriesStore.svelte.js';
|
||||||
|
import { addEntry, updateEntry } from '../../stores/entriesStore.svelte.js';
|
||||||
|
import { countryNames } from '../../shared/countries.js';
|
||||||
|
import { getCitiesForCountry, ALL_CITIES } from '../../shared/cities.js';
|
||||||
|
import SearchInput from '../../shared/SearchInput.svelte';
|
||||||
|
import PhotoEditor from './PhotoEditor.svelte';
|
||||||
|
import airplaneImg from '../../../assets/airplane.png';
|
||||||
|
import trainImg from '../../../assets/train.png';
|
||||||
|
import busImg from '../../../assets/bus.png';
|
||||||
|
import carImg from '../../../assets/car.png';
|
||||||
|
import shipImg from '../../../assets/ship.png';
|
||||||
|
import walkImg from '../../../assets/walk.png';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* entry = null → "new entry" mode
|
||||||
|
* entry = {...} → "edit" mode
|
||||||
|
* @type {{ entry?: import('../shared/types.js').JournalEntry | null, initialCountry?: string, onBack: () => void }}
|
||||||
|
*/
|
||||||
|
let { entry = null, initialCountry = '', onBack } = $props();
|
||||||
|
|
||||||
|
let isNew = !entry;
|
||||||
|
|
||||||
|
let cities = $state([...(entry?.location.cities ?? [])]);
|
||||||
|
let cityInput = $state('');
|
||||||
|
let country = $state(entry?.location.country ?? initialCountry);
|
||||||
|
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
||||||
|
let days = $state(String(entry?.days ?? ''));
|
||||||
|
let tripType = $state(entry?.tripType ?? '');
|
||||||
|
let photos = $state([...(entry?.photos ?? [])]);
|
||||||
|
let memo = $state(entry?.memo ?? '');
|
||||||
|
let transport = $state(entry?.transport ?? '');
|
||||||
|
|
||||||
|
let step = $state(1); // 1 | 2 | 3
|
||||||
|
|
||||||
|
let errors = $state({
|
||||||
|
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const transportOptions = [
|
||||||
|
{ value: 'flight', label: 'Flight', img: airplaneImg },
|
||||||
|
{ value: 'train', label: 'Train', img: trainImg },
|
||||||
|
{ value: 'bus', label: 'Bus', img: busImg },
|
||||||
|
{ value: 'car', label: 'Car', img: carImg },
|
||||||
|
{ value: 'ship', label: 'Ship', img: shipImg },
|
||||||
|
{ value: 'walk', label: 'Walk', img: walkImg },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEMO_MAX = 100;
|
||||||
|
let wordCount = $derived(memo.trim() === '' ? 0 : memo.trim().split(/\s+/).length);
|
||||||
|
let memoOverLimit = $derived(wordCount > MEMO_MAX);
|
||||||
|
|
||||||
|
function onMemoInput(e) {
|
||||||
|
const raw = e.currentTarget.value;
|
||||||
|
const words = raw.trim() === '' ? [] : raw.trim().split(/\s+/);
|
||||||
|
if (words.length > MEMO_MAX) {
|
||||||
|
memo = words.slice(0, MEMO_MAX).join(' ');
|
||||||
|
e.currentTarget.value = memo;
|
||||||
|
} else {
|
||||||
|
memo = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allEntries = $derived(getEntries());
|
||||||
|
let cityOptions = $derived(
|
||||||
|
country.trim()
|
||||||
|
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
|
||||||
|
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
function addCity(val) {
|
||||||
|
const trimmed = (val ?? cityInput).trim();
|
||||||
|
if (trimmed && !cities.includes(trimmed)) {
|
||||||
|
cities = [...cities, trimmed];
|
||||||
|
}
|
||||||
|
cityInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCity(c) {
|
||||||
|
cities = cities.filter(x => x !== c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStep() {
|
||||||
|
if (step === 1) {
|
||||||
|
clearErrors();
|
||||||
|
let hasError = false;
|
||||||
|
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
|
||||||
|
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
|
||||||
|
if (!date) { errors.date = 'Date is required.'; hasError = true; }
|
||||||
|
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
|
||||||
|
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
|
||||||
|
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
|
||||||
|
if (hasError) return;
|
||||||
|
}
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
if (step === 1) onBack();
|
||||||
|
else step--;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await addEntry({
|
||||||
|
title: `${cities.join(', ')}, ${country}`,
|
||||||
|
date,
|
||||||
|
days: Number(days),
|
||||||
|
tripType,
|
||||||
|
memo,
|
||||||
|
photos,
|
||||||
|
transport,
|
||||||
|
location: { cities, country },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await updateEntry(entry.id, {
|
||||||
|
date,
|
||||||
|
days: Number(days),
|
||||||
|
tripType,
|
||||||
|
transport,
|
||||||
|
memo,
|
||||||
|
photos,
|
||||||
|
location: { cities, country },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onBack();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="ghost-btn" onclick={prevStep}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||||
|
{step === 1 ? 'Back' : 'Previous'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
{#each [1,2,3] as s}
|
||||||
|
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-right">
|
||||||
|
{#if step < 3}
|
||||||
|
<button class="save-btn" onclick={nextStep}>Next</button>
|
||||||
|
{:else}
|
||||||
|
<button class="save-btn" onclick={save}>Save changes</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<div class="form">
|
||||||
|
|
||||||
|
{#if step === 1}
|
||||||
|
<h1 class="page-headline">
|
||||||
|
{isNew ? 'Journal your trip!' : 'Edit your trip'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="edit-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
|
||||||
|
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
|
||||||
|
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="edit-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
|
||||||
|
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||||
|
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
||||||
|
{#if cities.length > 0}
|
||||||
|
<div class="tags">
|
||||||
|
{#each cities as c}
|
||||||
|
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="edit-date">When did you <span class="kw">arrive</span>? <span class="req">*</span></label>
|
||||||
|
<input id="edit-date" class="input" type="date" bind:value={date} required />
|
||||||
|
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="edit-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
|
||||||
|
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
|
||||||
|
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
|
||||||
|
<div class="toggle-row">
|
||||||
|
{#each ['solo','friends','family'] as t}
|
||||||
|
<label class="toggle-opt" class:active={tripType === t}>
|
||||||
|
<input type="radio" name="edit-tripType" value={t} bind:group={tripType} />
|
||||||
|
{t === 'solo' ? '🧑 Solo' : t === 'friends' ? '👥 With friends' : '👨👩👧👦 With family'}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
|
||||||
|
<div class="transport-grid">
|
||||||
|
{#each transportOptions as opt}
|
||||||
|
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
|
||||||
|
<input type="radio" name="edit-transport" value={opt.value} bind:group={transport} />
|
||||||
|
<img src={opt.img} alt={opt.label} class="transport-img" />
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if step === 2}
|
||||||
|
<h2 class="step-title">Photos</h2>
|
||||||
|
<p class="step-sub">Optional — add or update photos from your trip</p>
|
||||||
|
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<h2 class="step-title">How was it?</h2>
|
||||||
|
<p class="step-sub">Optional — write a note about your trip</p>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label-row">
|
||||||
|
<label class="label" for="edit-memo">Your notes</label>
|
||||||
|
<span class="char-count" class:over={memoOverLimit}>{wordCount} / {MEMO_MAX} words</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="8" value={memo} oninput={onMemoInput}></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.topbar-left, .topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
.topbar-right { justify-content: flex-end; }
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.step-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.step-dot.active { background: var(--accent); transform: scale(1.25); }
|
||||||
|
.step-dot.done { background: var(--accent); opacity: 0.35; }
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||||
|
|
||||||
|
.scroll { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
.form {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 36px 48px 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-headline {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
.step-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-sub);
|
||||||
|
margin: -10px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
.req { color: var(--accent); font-size: 11px; }
|
||||||
|
.kw { color: var(--accent); }
|
||||||
|
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
|
||||||
|
|
||||||
|
.char-count { font-size: 11px; font-weight: 300; color: var(--text-sub); transition: color 0.15s; }
|
||||||
|
.char-count.over { color: #dc2626; }
|
||||||
|
.input-over { border-color: #fca5a5; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-h);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--accent-border); }
|
||||||
|
|
||||||
|
.textarea { resize: vertical; line-height: 1.6; }
|
||||||
|
|
||||||
|
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.toggle-opt {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
font-size: 14px; font-weight: 400; color: var(--text);
|
||||||
|
padding: 12px 14px; border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle-opt input { display: none; }
|
||||||
|
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
|
||||||
|
|
||||||
|
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.transport-opt { flex-direction: column; gap: 6px; padding: 16px 10px; }
|
||||||
|
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 12px; font-weight: 300; color: var(--accent);
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--accent-border);
|
||||||
|
border-radius: 20px; padding: 3px 10px 3px 12px;
|
||||||
|
}
|
||||||
|
.tag-rm {
|
||||||
|
background: none; border: none; color: var(--accent);
|
||||||
|
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
|
||||||
|
}
|
||||||
|
.tag-rm:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
import { removeJournal } from '../stores/journalStore.js';
|
import { removeEntry } from '../../stores/entriesStore.svelte.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('../stores/journalStore.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
|
/** @type {{ entry: import('../shared/types.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
|
||||||
let { entry, onBack, onEdit } = $props();
|
let { entry, onBack, onEdit } = $props();
|
||||||
|
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
removeJournal(entry.id);
|
removeEntry(entry.id);
|
||||||
onBack();
|
onBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
<span class="topbar-flag">{flagEmoji(entry.location.country)}</span>
|
<span class="topbar-flag">{flagEmoji(entry.location.country)}</span>
|
||||||
<div class="topbar-place">
|
<div class="topbar-place">
|
||||||
<span class="topbar-city">{entry.location.city}</span>
|
<span class="topbar-city">{entry.location.cities.join(', ')}</span>
|
||||||
<span class="topbar-country">{entry.location.country}</span>
|
<span class="topbar-country">{entry.location.country}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,6 +114,8 @@
|
|||||||
<p class="answer">
|
<p class="answer">
|
||||||
{#if entry.tripType === 'solo'}
|
{#if entry.tripType === 'solo'}
|
||||||
Just me — solo trip
|
Just me — solo trip
|
||||||
|
{:else if entry.tripType === 'family'}
|
||||||
|
With family
|
||||||
{:else}
|
{:else}
|
||||||
With friends
|
With friends
|
||||||
{/if}
|
{/if}
|
||||||
@@ -147,7 +149,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 52px;
|
height: 60px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -194,13 +196,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 6px 12px;
|
padding: 8px 14px;
|
||||||
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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[] }} */
|
/** @type {{ entries: import('../shared/types.js').JournalEntry[] }} */
|
||||||
let { entries } = $props();
|
let { entries } = $props();
|
||||||
|
|
||||||
let stats = $derived.by(() => {
|
let stats = $derived.by(() => {
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
||||||
const countries = [...new Set(entries.map(e => e.location.country))];
|
const countries = [...new Set(entries.map(e => e.location.country))];
|
||||||
const cities = [...new Set(entries.map(e => e.location.city))];
|
const cities = [...new Set(entries.flatMap(e => e.location.cities))];
|
||||||
|
|
||||||
const years = entries.map(e => new Date(e.date).getFullYear());
|
const years = entries.map(e => new Date(e.date).getFullYear());
|
||||||
const minYear = Math.min(...years);
|
const minYear = Math.min(...years);
|
||||||
493
src/lib/timeline/detail/NewEntryForm.svelte
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
<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}
|
||||||
|
<!-- headline -->
|
||||||
|
<h1 class="page-headline">
|
||||||
|
{#if country.trim()}
|
||||||
|
Journal your trip to <strong>{country}</strong>!
|
||||||
|
{:else}
|
||||||
|
Journal your trip!
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="nc-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
|
||||||
|
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
|
||||||
|
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="nc-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
|
||||||
|
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||||
|
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
||||||
|
{#if cities.length > 0}
|
||||||
|
<div class="tags">
|
||||||
|
{#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 <span class="kw">arrive</span>? <span class="req">*</span></label>
|
||||||
|
<input id="nc-date" class="input" type="date" bind:value={date} />
|
||||||
|
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="nc-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
|
||||||
|
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
|
||||||
|
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
|
||||||
|
<div class="toggle-row">
|
||||||
|
{#each ['solo','friends','family'] as t}
|
||||||
|
<label class="toggle-opt" class:active={tripType === t}>
|
||||||
|
<input type="radio" name="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 <span class="kw">get</span> there? <span class="req">*</span></label>
|
||||||
|
<div class="transport-grid">
|
||||||
|
{#each transportOptions as opt}
|
||||||
|
<label class="toggle-opt" class:active={transport === opt.value}>
|
||||||
|
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
|
||||||
|
<img src={opt.img} alt={opt.label} class="transport-img" />
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if step === 2}
|
||||||
|
<!-- ── STEP 2: Photos ── -->
|
||||||
|
<h2 class="step-title">Photos</h2>
|
||||||
|
<p class="step-sub">Optional — add photos from your trip</p>
|
||||||
|
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- ── STEP 3: Questions ── -->
|
||||||
|
<h2 class="step-title">
|
||||||
|
Your memories{cities.length > 0 ? ` of ${cities.join(', ')}` : country.trim() ? ` of ${country}` : ''}
|
||||||
|
</h2>
|
||||||
|
{#if cities.length > 0 || country.trim()}
|
||||||
|
<p class="step-sub">{cities.join(', ')}{cities.length > 0 && country.trim() ? `, ${country}` : country.trim()}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each questions as q, i}
|
||||||
|
<div class="q-card">
|
||||||
|
<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: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.page-headline {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.page-headline strong { font-weight: 600; }
|
||||||
|
.step-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
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; }
|
||||||
|
.kw { color: var(--accent); }
|
||||||
|
|
||||||
|
.ferr { font-size: 13px; font-weight: 500; 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; flex-wrap: wrap; }
|
||||||
|
.toggle-opt {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
font-size: 14px; font-weight: 400; color: var(--text);
|
||||||
|
padding: 16px 10px; border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.toggle-opt input { display: none; }
|
||||||
|
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
|
||||||
|
|
||||||
|
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 12px; font-weight: 300; color: var(--accent);
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--accent-border);
|
||||||
|
border-radius: 20px; padding: 3px 10px 3px 12px;
|
||||||
|
}
|
||||||
|
.tag-rm {
|
||||||
|
background: none; border: none; color: var(--accent);
|
||||||
|
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
|
||||||
|
}
|
||||||
|
.tag-rm:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* question cards */
|
||||||
|
.q-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1.5px solid var(--accent-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.q-text {
|
||||||
|
font-size: 14px;
|
||||||
|
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: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-h);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
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>
|
||||||
@@ -1,55 +1,57 @@
|
|||||||
<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);
|
||||||
|
let uploadError = $state('');
|
||||||
|
|
||||||
function remove(index) {
|
function remove(index) {
|
||||||
const next = photos.filter((_, i) => i !== index);
|
onchange(photos.filter((_, i) => i !== index));
|
||||||
onchange(next);
|
uploadError = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
uploadError = '';
|
||||||
onchange([...photos, ...dataUrls]);
|
try {
|
||||||
|
const urls = await Promise.all(files.map(uploadPhoto));
|
||||||
// reset so the same file can be picked again
|
onchange([...photos, ...urls]);
|
||||||
e.currentTarget.value = '';
|
} catch (err) {
|
||||||
|
uploadError = err?.message ?? 'Upload failed. Check Firebase Storage rules.';
|
||||||
|
} finally {
|
||||||
|
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>
|
||||||
|
|
||||||
<div class="photo-editor">
|
<div class="photo-editor">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<span class="label">Photos</span>
|
<span class="label">Photos</span>
|
||||||
<button type="button" class="add-btn" onclick={() => fileInput.click()}>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<path d="M12 5v14M5 12h14"/>
|
|
||||||
</svg>
|
|
||||||
Add photos
|
|
||||||
</button>
|
|
||||||
<input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden />
|
<input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden />
|
||||||
</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">
|
||||||
@@ -64,13 +66,17 @@
|
|||||||
</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>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadError}
|
||||||
|
<div class="upload-error">{uploadError}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -186,4 +192,15 @@
|
|||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
|
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
|
||||||
|
|
||||||
|
.upload-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { toPng } from 'html-to-image';
|
import { toPng } from 'html-to-image';
|
||||||
|
import { getTotalCount } from '../../layout/selection.svelte.js';
|
||||||
|
import profileImg from '../../../assets/profile.png';
|
||||||
|
|
||||||
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClose: () => void }} */
|
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClose: () => void }} */
|
||||||
let { entries, onClose } = $props();
|
let { entries, onClose } = $props();
|
||||||
|
|
||||||
let cardEl = $state(null);
|
let cardEl = $state(null);
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
|
|
||||||
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
||||||
const countries = [...new Set(entries.map(e => e.location.country))];
|
const countries = [...new Set(entries.map(e => e.location.country))];
|
||||||
const cities = [...new Set(entries.map(e => e.location.city))];
|
const cities = [...new Set(entries.flatMap(e => e.location.cities))];
|
||||||
|
|
||||||
// Continent days
|
// Continent days
|
||||||
const contDays = {};
|
const contDays = {};
|
||||||
@@ -68,6 +70,7 @@
|
|||||||
// Solo vs friends
|
// Solo vs friends
|
||||||
const soloCount = entries.filter(e => e.tripType === 'solo').length;
|
const soloCount = entries.filter(e => e.tripType === 'solo').length;
|
||||||
const friendCount = entries.filter(e => e.tripType === 'friends').length;
|
const friendCount = entries.filter(e => e.tripType === 'friends').length;
|
||||||
|
const familyCount = entries.filter(e => e.tripType === 'family').length;
|
||||||
|
|
||||||
// Most visited country
|
// Most visited country
|
||||||
const countryCounts = {};
|
const countryCounts = {};
|
||||||
@@ -79,16 +82,64 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
totalDays, countries, cities, contDays, topContinent,
|
totalDays, countries, cities, contDays, topContinent,
|
||||||
longest, flightHrs, soloCount, friendCount,
|
longest, flightHrs, soloCount, friendCount, familyCount,
|
||||||
favCountry, spanMonths, yearStart, yearEnd,
|
favCountry, spanMonths, yearStart, yearEnd,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function getFontDataUrl() {
|
||||||
|
// Get the actual woff2 URL the browser resolved for Bricolage Grotesque
|
||||||
|
for (const font of document.fonts) {
|
||||||
|
if (font.family.includes('Bricolage')) {
|
||||||
|
// font.status === 'loaded' means the browser has it
|
||||||
|
await font.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fetch the Google Fonts CSS to extract the real woff2 URL
|
||||||
|
const cssRes = await fetch(
|
||||||
|
'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500&display=swap',
|
||||||
|
{ headers: { 'User-Agent': 'Mozilla/5.0' } }
|
||||||
|
);
|
||||||
|
const css = await cssRes.text();
|
||||||
|
const match = css.match(/url\((https:\/\/fonts\.gstatic\.com[^)]+\.woff2)\)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const fontRes = await fetch(match[1]);
|
||||||
|
const blob = await fontRes.blob();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
if (!cardEl) return;
|
if (!cardEl) return;
|
||||||
downloading = true;
|
downloading = true;
|
||||||
try {
|
try {
|
||||||
const dataUrl = await toPng(cardEl, { pixelRatio: 3 });
|
await document.fonts.ready;
|
||||||
|
|
||||||
|
let fontFaceRule = '';
|
||||||
|
try {
|
||||||
|
const fontData = await getFontDataUrl();
|
||||||
|
if (fontData) {
|
||||||
|
fontFaceRule = `@font-face { font-family: 'Bricolage Grotesque'; src: url('${fontData}') format('woff2'); font-weight: 100 900; font-style: normal; }`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Inject font as a real <style> inside the card so html-to-image clones it
|
||||||
|
let injected = null;
|
||||||
|
if (fontFaceRule) {
|
||||||
|
injected = document.createElement('style');
|
||||||
|
injected.textContent = fontFaceRule;
|
||||||
|
cardEl.prepend(injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = { pixelRatio: 3 };
|
||||||
|
await toPng(cardEl, opts); // first pass loads resources
|
||||||
|
const dataUrl = await toPng(cardEl, opts);
|
||||||
|
|
||||||
|
if (injected) injected.remove();
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.download = 'my-journey.png';
|
a.download = 'my-journey.png';
|
||||||
a.href = dataUrl;
|
a.href = dataUrl;
|
||||||
@@ -134,35 +185,22 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-brand">MAP JOURNAL</span>
|
<span class="card-brand">JOURNI</span>
|
||||||
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
|
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<div class="profile-wrap">
|
||||||
|
<img class="profile-img" src={profileImg} alt="profile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hero stat -->
|
<!-- Hero stat -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<p class="hero-num">{stats.totalDays}</p>
|
<p class="hero-pre">You've colored</p>
|
||||||
<p class="hero-label">days of travel</p>
|
<p class="big-num">{getTotalCount() > 0 ? Math.round((stats.countries.length / getTotalCount()) * 100) : 0}%</p>
|
||||||
|
<p class="hero-post">of the world map.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stat grid -->
|
|
||||||
<div class="stat-grid">
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{stats.countries.length}</p>
|
|
||||||
<p class="stat-desc">countries</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{stats.cities.length}</p>
|
|
||||||
<p class="stat-desc">cities</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{stats.flightHrs}h</p>
|
|
||||||
<p class="stat-desc">in the air</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{Object.keys(stats.contDays).length}</p>
|
|
||||||
<p class="stat-desc">continents</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fun facts -->
|
<!-- Fun facts -->
|
||||||
<div class="facts">
|
<div class="facts">
|
||||||
@@ -175,7 +213,7 @@
|
|||||||
{#if stats.longest}
|
{#if stats.longest}
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<span class="fact-icon">📍</span>
|
<span class="fact-icon">📍</span>
|
||||||
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.city}</span>
|
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.cities.join(', ')}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if stats.flightHrs > 0}
|
{#if stats.flightHrs > 0}
|
||||||
@@ -192,33 +230,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</span>
|
<span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</span>
|
||||||
<span class="fact-text">{stats.soloCount} solo · {stats.friendCount} with friends</span>
|
<span class="fact-text">{stats.soloCount} solo{stats.friendCount > 0 ? ` · ${stats.friendCount} with friends` : ''}{stats.familyCount > 0 ? ` · ${stats.familyCount} with family` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Continent bar -->
|
|
||||||
{#if Object.keys(stats.contDays).length > 0}
|
|
||||||
<div class="cont-section">
|
|
||||||
<div class="cont-bar">
|
|
||||||
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
|
|
||||||
<div class="cont-seg" style="flex:{days}; background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"
|
|
||||||
title="{cont}: {days}d"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="cont-legend">
|
|
||||||
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
|
|
||||||
<span class="cont-item">
|
|
||||||
<span class="cont-dot" style="background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"></span>
|
|
||||||
{cont} {days}d
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span>mapjournal.app</span>
|
<span>journi</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -351,25 +370,41 @@
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Profile */
|
||||||
|
.profile-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.profile-img {
|
||||||
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hero */
|
/* Hero */
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.hero-num {
|
.hero-pre, .hero-post {
|
||||||
font-size: 88px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -4px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.hero-label {
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: #a5b4fc;
|
color: rgba(255,255,255,0.6);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-top: 4px;
|
margin: 0;
|
||||||
|
}
|
||||||
|
.big-num {
|
||||||
|
font-size: 88px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -4px;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stat grid */
|
/* Stat grid */
|
||||||
@@ -387,6 +422,10 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.stat-num {
|
.stat-num {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
241
src/lib/timeline/view/SharePreview.svelte
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<script>
|
||||||
|
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClick: () => void }} */
|
||||||
|
let { entries, onClick } = $props();
|
||||||
|
|
||||||
|
const continentMap = {
|
||||||
|
'Japan':'Asia','South Korea':'Asia','China':'Asia','Thailand':'Asia','Vietnam':'Asia',
|
||||||
|
'Indonesia':'Asia','Malaysia':'Asia','Singapore':'Asia','India':'Asia','Taiwan':'Asia',
|
||||||
|
'Philippines':'Asia','Cambodia':'Asia','Nepal':'Asia',
|
||||||
|
'France':'Europe','Spain':'Europe','Italy':'Europe','Germany':'Europe','UK':'Europe',
|
||||||
|
'Netherlands':'Europe','Portugal':'Europe','Greece':'Europe','Sweden':'Europe',
|
||||||
|
'Norway':'Europe','Denmark':'Europe','Finland':'Europe','Switzerland':'Europe',
|
||||||
|
'Austria':'Europe','Belgium':'Europe','Poland':'Europe','Czech Republic':'Europe',
|
||||||
|
'Hungary':'Europe','Croatia':'Europe','Turkey':'Europe',
|
||||||
|
'USA':'N. America','Canada':'N. America','Mexico':'N. America',
|
||||||
|
'Brazil':'S. America','Argentina':'S. America','Chile':'S. America','Peru':'S. America',
|
||||||
|
'Australia':'Oceania','New Zealand':'Oceania',
|
||||||
|
'Morocco':'Africa','Egypt':'Africa','Kenya':'Africa','South Africa':'Africa',
|
||||||
|
};
|
||||||
|
|
||||||
|
const continentColors = {
|
||||||
|
'Asia':'#f87171','Europe':'#818cf8','N. America':'#4ade80',
|
||||||
|
'S. America':'#fbbf24','Africa':'#fb923c','Oceania':'#c084fc',
|
||||||
|
};
|
||||||
|
|
||||||
|
let stats = $derived.by(() => {
|
||||||
|
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
||||||
|
const countries = [...new Set(entries.map(e => e.location.country))];
|
||||||
|
const contDays = {};
|
||||||
|
for (const e of entries) {
|
||||||
|
const c = continentMap[e.location.country] ?? 'Other';
|
||||||
|
contDays[c] = (contDays[c] ?? 0) + e.days;
|
||||||
|
}
|
||||||
|
const top = Object.entries(contDays).sort((a, b) => b[1] - a[1]);
|
||||||
|
return { totalDays, countries, contDays, top, trips: entries.length };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="preview-card" onclick={onClick} aria-label="Share your journey">
|
||||||
|
|
||||||
|
<div class="pc-bg"></div>
|
||||||
|
<div class="pc-grid-pattern"></div>
|
||||||
|
|
||||||
|
<div class="pc-header">
|
||||||
|
<span class="pc-brand">MAP JOURNAL</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-hero">
|
||||||
|
<span class="pc-num">{stats.totalDays}</span>
|
||||||
|
<span class="pc-label">days traveled</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-row">
|
||||||
|
<div class="pc-stat">
|
||||||
|
<span class="pc-stat-num">{stats.countries.length}</span>
|
||||||
|
<span class="pc-stat-label">countries</span>
|
||||||
|
</div>
|
||||||
|
<div class="pc-stat">
|
||||||
|
<span class="pc-stat-num">{stats.trips}</span>
|
||||||
|
<span class="pc-stat-label">trips</span>
|
||||||
|
</div>
|
||||||
|
<div class="pc-stat">
|
||||||
|
<span class="pc-stat-num">{Object.keys(stats.contDays).length}</span>
|
||||||
|
<span class="pc-stat-label">continents</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if stats.top.length > 0}
|
||||||
|
<div class="pc-bar-wrap">
|
||||||
|
<div class="pc-bar">
|
||||||
|
{#each stats.top as [cont, days]}
|
||||||
|
<div class="pc-seg" style="flex:{days}; background:{continentColors[cont] ?? '#818cf8'}"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="pc-bar-labels">
|
||||||
|
{#each stats.top.slice(0,3) as [cont, days]}
|
||||||
|
<span class="pc-bar-label" style="color:{continentColors[cont] ?? '#818cf8'}">{cont}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<svg class="pc-share-icon-corner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
|
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.preview-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 40px;
|
||||||
|
width: 100%;
|
||||||
|
background: #1a1630;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
text-align: left;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
.preview-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(124,58,237,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 90% 0%, rgba(124,58,237,0.2) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 60% at 0% 100%, rgba(99,102,241,0.1) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pc-grid-pattern {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.pc-brand {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
.pc-share-icon-corner {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 14px;
|
||||||
|
right: 14px;
|
||||||
|
color: #a5b4fc;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-hero {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.pc-num {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pc-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #a5b4fc;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.pc-stat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding-right: 12px;
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.pc-stat:last-child { border-right: none; padding-right: 0; padding-left: 12px; }
|
||||||
|
.pc-stat:not(:first-child):not(:last-child) { padding-left: 12px; }
|
||||||
|
.pc-stat-num {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pc-stat-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-bar-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.pc-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.pc-seg { border-radius: 2px; min-width: 3px; }
|
||||||
|
.pc-bar-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.pc-bar-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-cta {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #a5b4fc;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.08);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
<script>
|
<script>
|
||||||
import { flagEmoji } from '../shared/countries.js';
|
import { flagEmoji } from '../../shared/countries.js';
|
||||||
|
import default1 from '../../../assets/default-1.jpeg';
|
||||||
|
import default2 from '../../../assets/default-2.jpeg';
|
||||||
|
import default3 from '../../../assets/default-3.jpeg';
|
||||||
|
|
||||||
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onClick: () => void }} */
|
/** @type {{ entry: import('../shared/types.js').JournalEntry, onClick: () => void }} */
|
||||||
let { entry, onClick } = $props();
|
let { entry, onClick } = $props();
|
||||||
|
|
||||||
|
const defaults = [default1, default2, default3];
|
||||||
|
|
||||||
function formatDate(/** @type {string} */ iso) {
|
function formatDate(/** @type {string} */ iso) {
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainPhoto = $derived(entry.photos[0] ?? null);
|
// Pick a stable random default based on the entry id
|
||||||
|
function defaultPhoto(id) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return defaults[hash % defaults.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainPhoto = $derived(entry.photos[0] ?? defaultPhoto(entry.id));
|
||||||
let thumbPhotos = $derived(entry.photos.slice(1, 4));
|
let thumbPhotos = $derived(entry.photos.slice(1, 4));
|
||||||
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
|
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
|
||||||
|
|
||||||
@@ -29,10 +41,11 @@
|
|||||||
<div class="v-dot" aria-hidden="true"></div>
|
<div class="v-dot" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="v-content">
|
<div class="v-content">
|
||||||
<!-- Country above card -->
|
<!-- Country + cities above card -->
|
||||||
<div class="above-card">
|
<div class="above-card">
|
||||||
<span class="flag">{flagEmoji(entry.location.country)}</span>
|
<span class="flag">{flagEmoji(entry.location.country)}</span>
|
||||||
<span class="country-name">{entry.location.country}</span>
|
<span class="country-name">{entry.location.country}</span>
|
||||||
|
<span class="city-inline">· {entry.location.cities.join(', ')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
@@ -40,36 +53,10 @@
|
|||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onClick()}>
|
onkeydown={(e) => e.key === 'Enter' && onClick()}>
|
||||||
|
|
||||||
<!-- Trip badge — top-right of card, outside photo -->
|
|
||||||
<span class="trip-badge trip-badge--{entry.tripType}">
|
|
||||||
{entry.tripType === 'solo' ? 'Solo' : 'Friends'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Photos -->
|
<!-- Photos -->
|
||||||
<div class="photo-grid" class:has-thumbs={thumbPhotos.length > 0}>
|
<div class="photo-grid" class:has-thumbs={thumbPhotos.length > 0}>
|
||||||
<div class="photo-main">
|
<div class="photo-main">
|
||||||
{#if mainPhoto}
|
<img src={mainPhoto} alt="" loading="lazy" />
|
||||||
<img src={mainPhoto} alt="" loading="lazy"
|
|
||||||
onerror={(e) => {
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
e.currentTarget.nextElementSibling.style.display = 'flex';
|
|
||||||
}} />
|
|
||||||
<div class="photo-fallback" style="display:none">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
||||||
<path d="M21 15l-5-5L5 21"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="photo-fallback">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
||||||
<path d="M21 15l-5-5L5 21"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if thumbPhotos.length > 0}
|
{#if thumbPhotos.length > 0}
|
||||||
@@ -99,18 +86,17 @@
|
|||||||
|
|
||||||
<!-- Info bar -->
|
<!-- Info bar -->
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<span class="city">{entry.location.city}</span>
|
<span class="days-label">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if entry.transport}
|
{#if entry.transport}
|
||||||
<span class="transport-chip transport-chip--{entry.transport}">
|
<span class="transport-chip transport-chip--{entry.transport}">
|
||||||
{@html transportIcons[entry.transport] ?? ''}
|
{@html transportIcons[entry.transport] ?? ''}
|
||||||
{transportLabel}
|
{transportLabel}
|
||||||
</span>
|
</span>
|
||||||
<span class="dot-sep">·</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<span>{formatDate(entry.date)}</span>
|
<span class="trip-badge trip-badge--{entry.tripType}">
|
||||||
<span class="dot-sep">·</span>
|
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
|
||||||
<span>{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,6 +158,14 @@
|
|||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
}
|
}
|
||||||
|
.city-inline {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-sub);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card ── */
|
/* ── Card ── */
|
||||||
.entry-card {
|
.entry-card {
|
||||||
@@ -191,21 +185,18 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Trip badge — absolute top-right of card ── */
|
/* ── Trip badge — inline in info bar ── */
|
||||||
.trip-badge {
|
.trip-badge {
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 2;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
padding: 3px 10px;
|
padding: 2px 8px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
backdrop-filter: blur(6px);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.trip-badge--solo { background: rgba(245,158,11,0.85); color: #fff; }
|
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; border: 1px solid rgba(245,158,11,0.25); }
|
||||||
.trip-badge--friends { background: rgba(124,58,237,0.85); color: #fff; }
|
.trip-badge--friends { background: rgba(124,58,237,0.07); color: #7c3aed; border: 1px solid rgba(124,58,237,0.2); }
|
||||||
|
.trip-badge--family { background: rgba(16,185,129,0.08); color: #059669; border: 1px solid rgba(16,185,129,0.2); }
|
||||||
|
|
||||||
/* ── Photo grid — fixed height, always consistent ── */
|
/* ── Photo grid — fixed height, always consistent ── */
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
@@ -293,16 +284,12 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
gap: 8px;
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
.city {
|
.days-label {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--text);
|
color: var(--text-sub);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
import { get } from 'svelte/store';
|
import { getEntries } from '../../stores/entriesStore.svelte.js';
|
||||||
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 './JournalDetail.svelte';
|
import JournalDetail from '../detail/JournalDetail.svelte';
|
||||||
import EditForm from './EditForm.svelte';
|
import EditForm from '../detail/EditForm.svelte';
|
||||||
|
import NewEntryForm from '../detail/NewEntryForm.svelte';
|
||||||
import ShareCard from './ShareCard.svelte';
|
import ShareCard from './ShareCard.svelte';
|
||||||
|
import SharePreview from './SharePreview.svelte';
|
||||||
|
|
||||||
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
|
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {}, onGoToMap = () => {} } = $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);
|
||||||
@@ -24,11 +25,7 @@
|
|||||||
});
|
});
|
||||||
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 = $state(get(journals));
|
let entries = $derived(getEntries());
|
||||||
$effect(() => {
|
|
||||||
const unsub = journals.subscribe((v) => { entries = v; });
|
|
||||||
return unsub;
|
|
||||||
});
|
|
||||||
|
|
||||||
let sortKey = $state('date-desc');
|
let sortKey = $state('date-desc');
|
||||||
|
|
||||||
@@ -53,7 +50,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); }} onSaved={() => { onGoToMap(); }} />
|
||||||
</div>
|
</div>
|
||||||
{:else if view === 'edit' && selected}
|
{:else if view === 'edit' && selected}
|
||||||
<div class="detail-scroll">
|
<div class="detail-scroll">
|
||||||
@@ -68,62 +65,55 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="right-panel">
|
<div class="list-view">
|
||||||
<div class="right-inner">
|
<div class="page-header">
|
||||||
<div class="page-header">
|
<h1 class="page-title">My Journey</h1>
|
||||||
<h1 class="page-title">My Journey</h1>
|
<button class="new-btn" onclick={() => { view = 'new'; }}>
|
||||||
<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">
|
||||||
<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"/>
|
||||||
<path d="M12 5v14M5 12h14"/>
|
</svg>
|
||||||
</svg>
|
Add trip
|
||||||
New entry
|
</button>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="left-col">
|
||||||
|
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
|
||||||
|
|
||||||
|
{#if sortedEntries.length === 0}
|
||||||
|
<p class="empty">No journal entries yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="sort-row">
|
||||||
|
<span class="sort-label">Sort by</span>
|
||||||
|
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
|
||||||
|
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
|
||||||
|
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
|
||||||
|
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A → Z</option>
|
||||||
|
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z → A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<ol class="v-list">
|
||||||
|
{#each sortedEntries as entry, i (entry.id)}
|
||||||
|
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
|
||||||
|
<li class="year-marker" aria-hidden="true">
|
||||||
|
<span class="year-label">{getYear(entry.date)}</span>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<footer class="page-footer">
|
||||||
|
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
|
|
||||||
|
|
||||||
{#if sortedEntries.length > 0}
|
{#if sortedEntries.length > 0}
|
||||||
<button class="share-nudge" onclick={() => (showShare = true)}>
|
<div class="right-col">
|
||||||
<span class="nudge-left">
|
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
|
||||||
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
|
|
||||||
</svg>
|
|
||||||
Share your journey
|
|
||||||
</span>
|
|
||||||
<span class="nudge-right">
|
|
||||||
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'} · save as PNG →
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#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>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<footer class="page-footer">
|
|
||||||
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -144,42 +134,51 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Left panel ── */
|
/* ── List view wrapper (scrollable) ── */
|
||||||
.left-panel {
|
.list-view {
|
||||||
width: 260px;
|
flex: 1;
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-right: 1px solid var(--border);
|
padding: 48px 0 80px;
|
||||||
background: var(--bg-raised);
|
box-sizing: border-box;
|
||||||
padding: 40px 28px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Right panel ── */
|
.page-header,
|
||||||
.right-panel {
|
.two-col {
|
||||||
|
max-width: 960px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 48px;
|
||||||
|
padding-right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two-column below header ── */
|
||||||
|
.two-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-col {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner container with max-width + generous side padding */
|
.right-col {
|
||||||
.right-inner {
|
width: 260px;
|
||||||
max-width: 640px;
|
flex-shrink: 0;
|
||||||
margin: 0 auto;
|
position: sticky;
|
||||||
padding: 40px 48px 80px;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Responsive: narrow viewport ── */
|
@media (max-width: 900px) {
|
||||||
@media (max-width: 700px) {
|
.right-col { display: none; }
|
||||||
.journal-page { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
}
|
||||||
.left-panel {
|
|
||||||
width: 100%;
|
@media (max-width: 760px) {
|
||||||
border-right: none;
|
.list-view { padding: 32px 24px 60px; }
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
.right-panel { overflow-y: unset; }
|
|
||||||
.right-inner { padding: 24px 20px 60px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Detail view ── */
|
/* ── Detail view ── */
|
||||||
@@ -293,38 +292,4 @@
|
|||||||
}
|
}
|
||||||
.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>
|
||||||
419
src/lib/world-map/JourneyView.svelte
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
<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-animation.png';
|
||||||
|
|
||||||
|
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
||||||
|
|
||||||
|
const HOME_CODE = '203';
|
||||||
|
|
||||||
|
const PLANE_SIZE = 26;
|
||||||
|
|
||||||
|
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 = 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!' });
|
||||||
|
setTimeout(() => close(), 2500);
|
||||||
|
} 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 animation' : 'Map animation'}
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" onclick={close}>
|
||||||
|
✕ Back to Journaling
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.journey-frame { width: 100%; height: 100%; overflow: hidden; position: relative; background: #a4c8e0; }
|
||||||
|
.journey-frame.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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<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, getTotalCount } from '../layout/selection.svelte.js';
|
||||||
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
|
|
||||||
@@ -8,11 +9,38 @@
|
|||||||
'Europe': '#3b82f6',
|
'Europe': '#3b82f6',
|
||||||
'Asia': '#ef4444',
|
'Asia': '#ef4444',
|
||||||
'Africa': '#f97316',
|
'Africa': '#f97316',
|
||||||
'N. America': '#22c55e',
|
'N. America': '#ec4899',
|
||||||
'S. America': '#eab308',
|
'S. America': '#eab308',
|
||||||
'Oceania': '#a855f7'
|
'Oceania': '#a16207'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const countryNameById = $derived.by(() => {
|
||||||
|
const map = { XK: 'Kosovo' };
|
||||||
|
for (const g of worldData.objects.countries.geometries) {
|
||||||
|
map[g.id] = g.properties?.name || g.id;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
let visitedCountries = $derived(
|
||||||
|
[...getSelected()].map(id => countryNameById[id]).filter(Boolean).sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
let visitedByContinent = $derived.by(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const id of getSelected()) {
|
||||||
|
const cont = getContinent(id);
|
||||||
|
if (cont) {
|
||||||
|
if (!map[cont]) map[cont] = [];
|
||||||
|
map[cont].push(countryNameById[id] || id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cont of Object.keys(map)) {
|
||||||
|
map[cont].sort();
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
let counts = $derived.by(() => {
|
let counts = $derived.by(() => {
|
||||||
const c = {};
|
const c = {};
|
||||||
for (const cont of CONTINENTS) c[cont] = 0;
|
for (const cont of CONTINENTS) c[cont] = 0;
|
||||||
@@ -70,29 +98,37 @@
|
|||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
<h2 class="headline">your statistics</h2>
|
<h2 class="headline">your statistics</h2>
|
||||||
|
|
||||||
<span class="bar-label">visited countries</span>
|
<div class="total-bar-bg">
|
||||||
<div class="total-bar-wrap">
|
<div class="total-bar-fill" style="width: {pct}%"></div>
|
||||||
<div class="total-bar-bg">
|
<span class="bar-pct">{pct}%</span>
|
||||||
<div class="total-bar-fill" style="width: {pct}%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="total-bar-text">{total} / {grandTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="total-bar-text">{total} / {grandTotal} countries visited</span>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<span class="bar-label">by continent</span>
|
<span class="bar-label">by continent</span>
|
||||||
{#each CONTINENTS as continent}
|
{#each CONTINENTS as continent}
|
||||||
{@const contTotal = continentTotals[continent]}
|
{@const contTotal = continentTotals[continent]}
|
||||||
<div class="row">
|
<div class="row tooltip-wrap">
|
||||||
<span class="dot" style="background: {continentColors[continent]}"></span>
|
<span class="dot" style="background: {continentColors[continent]}"></span>
|
||||||
<span class="label">{continent}</span>
|
<span class="label">{continent}</span>
|
||||||
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
|
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
|
||||||
|
{#if visitedByContinent[continent]?.length > 0}
|
||||||
|
<div class="tooltip-list">
|
||||||
|
{#each visitedByContinent[continent].slice(0, 10) as country}
|
||||||
|
<span class="tooltip-item">{country}</span>
|
||||||
|
{/each}
|
||||||
|
{#if visitedByContinent[continent].length > 10}
|
||||||
|
<span class="tooltip-item tooltip-more">...</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="donut-wrap">
|
<div class="donut-wrap">
|
||||||
{#if segments.length > 0}
|
{#if segments.length > 0}
|
||||||
<svg viewBox="0 0 180 180" class="donut-svg">
|
<svg viewBox="-25 -25 230 230" class="donut-svg">
|
||||||
{#each segments as seg}
|
{#each segments as seg}
|
||||||
<g class="seg-group">
|
<g class="seg-group">
|
||||||
<path d={seg.path} fill={seg.color} />
|
<path d={seg.path} fill={seg.color} />
|
||||||
@@ -102,7 +138,7 @@
|
|||||||
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
|
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg viewBox="0 0 180 180" class="donut-svg">
|
<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="65" fill="var(--border)" />
|
||||||
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
|
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -204,16 +240,9 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-bar-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-bar-bg {
|
.total-bar-bg {
|
||||||
flex: 1;
|
position: relative;
|
||||||
height: 18px;
|
height: 28px;
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -227,11 +256,24 @@
|
|||||||
min-width: 0;
|
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 {
|
.total-bar-text {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
white-space: nowrap;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
@@ -276,12 +318,12 @@
|
|||||||
.donut-wrap {
|
.donut-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 20px 0;
|
margin: 8px 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-svg {
|
.donut-svg {
|
||||||
width: 160px;
|
width: 180px;
|
||||||
height: 160px;
|
height: 180px;
|
||||||
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15));
|
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +340,41 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-list {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
background: var(--text-h);
|
||||||
|
color: var(--bg-raised);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 20;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-wrap:hover .tooltip-list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-item {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-item + .tooltip-item {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.disclaimer {
|
.disclaimer {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-sub);
|
color: var(--text-sub);
|
||||||
|
|||||||
@@ -3,26 +3,85 @@
|
|||||||
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, setTotalCount, getFlashing } from '../layout/selection.svelte.js';
|
||||||
|
import { getUserProfile } from '../auth/userStore.svelte.js';
|
||||||
|
import { nameToId } from '../shared/countries.js';
|
||||||
|
import homeIconUrl from '../../assets/home.png';
|
||||||
|
import crayonCursorUrl from '../../assets/logo-cursor.png';
|
||||||
|
|
||||||
let { onCountryClick = (_name) => {} } = $props();
|
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 (id === homeCode) return HOME_COLOR;
|
||||||
|
if (!sel.has(id)) return UNVISITED_COLOR;
|
||||||
|
return VISITED_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryHoverColor(d, sel, homeCode) {
|
||||||
|
const id = effId(d);
|
||||||
|
if (id === homeCode) return HOME_COLOR_HOVER;
|
||||||
|
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
|
||||||
|
return VISITED_COLOR_HOVER;
|
||||||
|
}
|
||||||
|
|
||||||
let frameEl;
|
let frameEl;
|
||||||
|
let _paths = $state(null);
|
||||||
|
let _g = null;
|
||||||
|
let _pathFn = null;
|
||||||
|
let _countries = 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 +89,60 @@
|
|||||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHomeCode() {
|
||||||
|
const name = getUserProfile()?.homeCountry;
|
||||||
|
return name ? (nameToId[name] ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllFills() {
|
||||||
|
const sel = getSelected();
|
||||||
|
const hc = getHomeCode();
|
||||||
|
if (!_paths || !_g) return;
|
||||||
|
_paths.attr('fill', d => countryColor(d, sel, hc));
|
||||||
|
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(updateAllFills);
|
||||||
|
|
||||||
|
function placeHomeMarker() {
|
||||||
|
if (!_g || !_pathFn || !_countries) return;
|
||||||
|
_g.selectAll('.home-marker').remove();
|
||||||
|
const name = getUserProfile()?.homeCountry;
|
||||||
|
if (!name) return;
|
||||||
|
const found = _countries.find(f => f.properties.name === name);
|
||||||
|
if (!found) return;
|
||||||
|
const [cx, cy] = _pathFn.centroid(found);
|
||||||
|
if (isNaN(cx) || isNaN(cy)) return;
|
||||||
|
const SIZE = 14;
|
||||||
|
_g.append('image')
|
||||||
|
.attr('class', 'home-marker')
|
||||||
|
.attr('href', homeIconUrl)
|
||||||
|
.attr('x', cx - SIZE / 2)
|
||||||
|
.attr('y', cy - SIZE / 2)
|
||||||
|
.attr('width', SIZE)
|
||||||
|
.attr('height', SIZE)
|
||||||
|
.style('pointer-events', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(placeHomeMarker);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const flashSet = getFlashing();
|
||||||
|
const paths = _paths; // reactive read so effect re-runs when _paths is set
|
||||||
|
if (!paths || flashSet.size === 0) return;
|
||||||
|
paths
|
||||||
|
.filter(d => flashSet.has(effId(d)))
|
||||||
|
.each(function() {
|
||||||
|
d3.select(this).interrupt()
|
||||||
|
.transition().duration(200).attr('fill', '#facc15')
|
||||||
|
.transition().duration(200).attr('fill', '#fb923c')
|
||||||
|
.transition().duration(200).attr('fill', '#facc15')
|
||||||
|
.transition().duration(200).attr('fill', '#fb923c')
|
||||||
|
.transition().duration(200).attr('fill', '#facc15')
|
||||||
|
.transition().duration(400).attr('fill', VISITED_COLOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const width = frameEl.clientWidth;
|
const width = frameEl.clientWidth;
|
||||||
const height = frameEl.clientHeight;
|
const height = frameEl.clientHeight;
|
||||||
@@ -42,71 +155,92 @@
|
|||||||
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';
|
||||||
|
});
|
||||||
|
|
||||||
|
_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).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');
|
||||||
|
|
||||||
function updateFill(sel) {
|
const tooltip = d3.select(frameEl)
|
||||||
sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
|
.append('div')
|
||||||
g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
|
.attr('class', 'tooltip')
|
||||||
}
|
.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) => {
|
||||||
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');
|
const s = getSelected();
|
||||||
|
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, getHomeCode()));
|
||||||
tooltip.style('display', 'block').text(d.properties.name);
|
tooltip.style('display', 'block').text(d.properties.name);
|
||||||
})
|
})
|
||||||
.on('mousemove', (event) => {
|
.on('mousemove', (event) => {
|
||||||
const [x, y] = d3.pointer(event, frameEl);
|
const [x, y] = d3.pointer(event, frameEl);
|
||||||
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
|
tooltip.style('left', (x + 22) + 'px').style('top', (y - 28) + 'px');
|
||||||
})
|
})
|
||||||
.on('mouseleave', (event, d) => {
|
.on('mouseleave', (event, d) => {
|
||||||
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
|
const s = getSelected();
|
||||||
|
d3.select(event.currentTarget).attr('fill', countryColor(d, s, getHomeCode()));
|
||||||
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 c = _g.append('circle')
|
||||||
.attr('cx', cx).attr('cy', cy).attr('r', 2)
|
.attr('class', 'micro-state')
|
||||||
.attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff')
|
.datum(d)
|
||||||
.attr('stroke', '#94a3b8').attr('stroke-width', 0.5);
|
.attr('cx', cx)
|
||||||
|
.attr('cy', cy)
|
||||||
|
.attr('r', 2)
|
||||||
|
.attr('fill', countryColor(d, getSelected(), getHomeCode()))
|
||||||
|
.attr('stroke', '#94a3b8')
|
||||||
|
.attr('stroke-width', 0.5);
|
||||||
attachEvents(c);
|
attachEvents(c);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMicrostates();
|
renderMicrostates();
|
||||||
|
placeHomeMarker();
|
||||||
|
|
||||||
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,10 +252,11 @@
|
|||||||
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);
|
updateAllFills();
|
||||||
renderMicrostates();
|
renderMicrostates();
|
||||||
|
placeHomeMarker();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +269,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={frameEl} class="map-frame"></div>
|
<div bind:this={frameEl} class="map-frame" style="cursor: url({crayonCursorUrl}) 4 28, crosshair;"></div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.map-frame {
|
.map-frame {
|
||||||
@@ -145,9 +280,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: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|||||||
9
storage.rules
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service firebase.storage {
|
||||||
|
match /b/{bucket}/o {
|
||||||
|
match /{allPaths=**} {
|
||||||
|
allow read: if true;
|
||||||
|
allow write: if request.auth != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
})
|
})
|
||||||
|
|||||||