68 Commits

Author SHA1 Message Date
522e41cb79 Update README.md 2026-06-17 02:24:15 +00:00
caaae887f9 fix: animation bug fix 2026-06-17 11:21:20 +09:00
3ae1b25b0c Merge branch 'main' of https://git.prototyping.id/20256426/Map-Jurnal 2026-06-17 09:00:54 +09:00
e6bc251386 final cleanup 2026-06-17 09:00:20 +09:00
4e1981e1f6 Update README.md 2026-06-16 17:15:17 +00:00
59b89fb276 Aktualizovat README.md 2026-06-16 16:50:57 +00:00
5231b0ead7 update README 2026-06-16 16:50:25 +00:00
6fc0172ec3 updated README 2026-06-17 01:45:32 +09:00
c27200d9cd Aktualizovat README.md 2026-06-16 16:26:42 +00:00
8fbe40f178 fix; timeline sorting 2026-06-17 01:20:32 +09:00
8b52437655 Merge branch 'main' of https://git.prototyping.id/20256426/Map-Jurnal 2026-06-17 01:15:52 +09:00
5e36f6e140 bug fix 2026-06-17 01:15:43 +09:00
2f49c35128 Aktualizovat README.md 2026-06-16 16:13:51 +00:00
3e43d09723 published the website 2026-06-17 01:04:45 +09:00
0f1e43d82d Aktualizovat README.md 2026-06-16 15:54:33 +00:00
944b73d215 Aktualizovat README.md 2026-06-16 15:54:00 +00:00
f22b0f1b28 Aktualizovat README.md 2026-06-16 15:49:34 +00:00
5e943df6ef Aktualizovat README.md 2026-06-16 15:46:31 +00:00
cbdf489f1b add README 2026-06-17 00:32:23 +09:00
e86ec2bbb9 final refactoring 2026-06-17 00:31:56 +09:00
2ecd3ca81d updating gitignore 2026-06-16 23:28:28 +09:00
1743e7fcbe added saving photos to firebase 2026-06-16 23:18:20 +09:00
d614ddb322 fix: UI world map 2026-06-16 22:31:17 +09:00
Haeri Kim
ed415a78a1 added default images for card ui & fixed ui 2026-06-16 21:55:29 +09:00
Haeri Kim
9109d6a861 feat: 3-step edit form, share card redesign, map fixes
- EditForm: refactored to 3-step flow matching NewEntryForm (details → photos → memo), transport icons with bigger images
- ShareCard: profile image, "You've colored X% of the world map" hero stat, removed stat boxes and continent bar, font embedding fix for PNG export, renamed brand to Journi
- JourneyView: auto-close after journey complete
- WorldMap: removed home marker icon, fix home marker reposition on resize

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:01:26 +09:00
8d36c3faca after merge fix 2026-06-16 20:14:45 +09:00
haerikimmm
b518016a21 fix: home country always purple regardless of visited status 2026-06-16 19:43:54 +09:00
haerikimmm
665472b281 fix: color home country purple on world map 2026-06-16 19:42:42 +09:00
haerikimmm
2226a483c5 chore: reorganize timeline into view/ and detail/ folders, move home.png to assets/ 2026-06-16 19:41:08 +09:00
haerikimmm
93636b6968 chore: remove unused duplicate files 2026-06-16 19:38:38 +09:00
haerikimmm
5718bca963 fix: constrain timeline content width with side margins 2026-06-16 19:34:49 +09:00
haerikimmm
6f41f6e53e fix: proper 2-column layout with scroll, header above columns, side padding 2026-06-16 19:33:50 +09:00
haerikimmm
d157055ab7 fix: journal tab name, 2-column layout with SharePreview on right 2026-06-16 19:32:34 +09:00
haerikimmm
76d7e815c3 style: question-form labels for trip detail fields 2026-06-16 19:26:05 +09:00
haerikimmm
c7cf053105 feat: static city suggestions by country, fix city dropdown, coloring animation on save 2026-06-16 19:21:26 +09:00
haerikimmm
a7079c1f18 feat: add home marker on map, crayon cursor in worldmap, remove logo from topbar 2026-06-16 19:01:59 +09:00
haerikimmm
cf9717149f style home country picker page with home image and app theme 2026-06-16 18:40:51 +09:00
haerikimmm
ec4eea0977 fix save journal button and reactive city filtering by country 2026-06-16 18:32:48 +09:00
haerikimmm
92fae28383 merge globe 3D view with transport icons and real journal data 2026-06-16 17:49:07 +09:00
haerikimmm
b3c5fbe3dd fix: add journalStore shim and sync journals writable store with entriesStore 2026-06-16 17:42:48 +09:00
haerikimmm
8e9b40cc69 merge remote main: keep our journal-driven selection and transport animation 2026-06-16 17:32:11 +09:00
haerikimmm
d389b496b4 merge feature/timeline into main 2026-06-16 17:27:19 +09:00
haerikimmm
bf2700efb7 add animated logo and slogan to login page 2026-06-16 17:26:18 +09:00
0a823948df updated comunication with firebase 2026-06-16 16:18:28 +09:00
haerikimmm
d2fb40f692 added logo image and slogan to sign in page 2026-06-16 15:20:07 +09:00
36f0c25721 layout update 2026-06-16 15:07:16 +09:00
d09946161f Merge branch 'feature/timeline' 2026-06-16 13:14:49 +09:00
87993ae9c6 updated animation 2026-06-16 12:36:37 +09:00
65248fd082 updated adding jurney 2026-06-16 01:39:59 +09:00
e9662754c4 add globe animation 2026-06-15 23:25:58 +09:00
haerikimmm
06e5fe5593 feat: add transport image icons and expand country list to match world map 2026-06-15 19:54:45 +09:00
haerikimmm
dd7932ea4e feat: Firebase integration, 3-step new trip form, full country list, and UI polish
- Connect Firestore for journal entries and visited countries (real-time onSnapshot)
- Connect Firebase Storage for photo uploads
- Add NewEntryForm: 3-step flow (trip details → photos → reflection questions)
- Expand country list to full world-atlas dataset (~240 countries) matching the map
- Filter city suggestions by selected country in both NewEntryForm and EditForm
- Redesign StatsPanel as floating horizontal card with donut chart and progress bar
- Center timeline layout with responsive side margins
- Replace "entry" language with "trip" throughout (Add trip, Save trip, Delete trip)
- Remove footer from Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:54:45 +09:00
haerikimmm
40e75f30e8 changed to centered layout 2026-06-15 19:50:13 +09:00
965f677368 fix after merge 2026-06-15 12:35:57 +09:00
611cc0b626 Merge branch 'feature/timeline' into main 2026-06-15 12:29:36 +09:00
70352be01b animation plane shape 2026-06-15 12:14:57 +09:00
haerikimmm
6cee6095ed added sharable stats 2026-06-15 10:20:55 +09:00
bd001a71fa add jurney animation 2026-06-15 00:06:10 +09:00
Haeri Kim
8422c6e34f changed app name, added new journal entry from map and timeline 2026-06-14 10:59:59 +09:00
Haeri Kim
cdf3643622 added edit page and revised edit form 2026-06-14 10:47:21 +09:00
Haeri Kim
aadc80b7a8 added firebase & changed main tab color, uyear stand out 2026-06-14 10:24:45 +09:00
c9c94d670f Merge feature/timeline into main 2026-06-12 20:06:25 +09:00
haerikimmm
9be793e2dd added jounal page 2026-06-12 15:55:43 +09:00
haerikimmm
cb0e5602c8 added flag, changed the location of the country name 2026-06-12 15:34:43 +09:00
haerikimmm
326b52b858 changed the location of the travel summary to left 2026-06-11 13:30:18 +09:00
haerikimmm
0848418453 changed font style, added journey summary, changed card ui 2026-06-11 13:21:09 +09:00
haerikimmm
6b13522d6d clean up folders, created sub-components 2026-06-11 11:28:58 +09:00
63 changed files with 5253 additions and 1773 deletions

13
.claude/launch.json Normal file
View File

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

View File

@@ -0,0 +1,17 @@
logo.png,1781605272907,e1d8cae38fa1d4a97f6847ccdc69dae37d8b481e6862625b6d8a6565137a622b
index.html,1781628774437,ba46c34c4641f9cba330944cbd3f9b21ee4cb43addcf58dc1876428f5aef2c0c
assets/index-DvsKFHEX.css,1781628774437,5c4f357ff59c59917ce7c70ee28b29bc28c9d7eec3f6afd12e4c2cc8120537e1
assets/walk-2q6BKbeA.png,1781628774437,19a9f5d599221a0077761a7777285ca17dbc2775a9c4096304098602f2067b87
assets/airplane-DW4h3HkK.png,1781628774437,8d70ae12e46f5abdb4e1ccdf59af4bcab1805229d02f1260321f1f61d6375af1
assets/ship-xaUNk1p4.png,1781628774438,58b126e20c22710d5af92a45b6ec8af6e0b3dea30a743f9e8b91e849ed66cd91
assets/car-C6BGShf6.png,1781628774437,ac10c46e0a8245a9e52b8c3cebe110112f57764c9bda1fe93d4fbc077e79a1b2
assets/bus-Dy8WA7au.png,1781628774437,8285587abd857cce3b7083e799a8d2a95b019cfc40546e914cc2b8348c448c10
assets/default-3-BcrUpMyO.jpeg,1781628774448,f10d24e80f9f94ddf40d1103ea18f96214e89c18102c75be6b816c8ee0a2b4b9
assets/logo-signin-BAMDzBb4.png,1781628774437,7e5d35cac2cea449b27ef72e386e66e1b9c8cbb9261a26b85fda4cb2d89ac226
assets/train-DAjH23UQ.png,1781628774437,9b3a2b3b0376f74a47daf69b2f202d757fd8425edede28a8263d2ef5994ed5f2
assets/default-2-CoZoomA5.jpeg,1781628774448,fbdb512018aba7f68167796632defe75019af5432ed0bfc48d0e24851c47261f
assets/profile-lvFMnGZN.png,1781628774437,0892c6fcffa6be93a86ddedc09b6c26ad725fd1dd66479b4382dbfddcb6d392f
assets/default-1-CZLThRLv.jpeg,1781628774448,72f1614c5862c5fa1b79db6130985b681cd971e4c415758990dc1fd3c9eee1a4
assets/home-D9_bbHBx.png,1781628774423,201c0fa3a0d38a8ce25c5494d0bd1f2d5d7e0f03fb07cd9c34b3e7593858899f
DB-diagram.PNG,1781628177434,978dcc3d9b7e059510c59d5c8a836018dfdb4b6cf59a9e362a89a5a6f9cb6f26
assets/index-gfLawFjf.js,1781628774466,b5e58d7bfb36663198ac3fbbece0600acef7c8b8822ab9c1132cfc0a68ce214a

5
.firebaserc Normal file
View File

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

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.claude/

140
README.md
View File

@@ -1,43 +1,133 @@
# Svelte + Vite
# Map Journal
This template should help get you started developing with Svelte in Vite.
**Authors:** Tomas Horsky, Haeri Kim
## Recommended IDE Setup
**Student IDs:** 20256426, 20254236
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
**Emails:** tomashorsky@kaist.ac.kr , kimhaeri@kaist.ac.kr
## Need an official Svelte framework?
**Live website:** <https://map-jurnal.web.app/>
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
**Repository:** <https://git.prototyping.id/20256426/Map-Jurnal.git>
## Technical considerations
**Video Demo:** https://youtu.be/j8-Ue4wanRU
**Why use this over SvelteKit?**
---
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
## Overview
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Journi is a web application for journaling and visualizing travel experiences. Users log trips by country, pinning cities, dates, transport modes, and photos. Then user can explore their journey on an interactive world map, timeline view or can play short animations overviewing his trips.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
### How It Works
**Why include `.vscode/extensions.json`?**
1. **Sign in** with a Google account.
2. **Select your home country** — it is automatically marked as visited on the map.
3. **Add journal entries**:
- **Step 0** — Click on country in map you visited or click add trip in timeline.
- **Step 1** — Choose country, cities, arrival date, days stayed, trip type (solo/friends/family), and transport (flight/train/bus/car/ship/walk).
- **Step 2** — Upload photos (stored in Firebase Storage).
- **Step 3** — Answer three random reflective questions about the trip (e.g., *"What was the most unexpected thing that happened?"*).
4. **Edit entries** through the same form, pre-filled with existing data.
5. **View your journey** on a D3-powered world map where visited countries are highlighted and animated flight paths connect entries in chronological order.
6. **Browse a timeline** sorted by date, country, or recency.
7. **Share** a generated summary card to show off your stats.
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
---
**Why enable `checkJs` in the JS template?**
## Backend — Firebase
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
![Firestore diagram](public/DB-diagram.PNG)
Journi uses Firebase as its backend. Users sign in with Google through Firebase Authentication, and their data is linked to their user ID. Firestore stores user profiles in `users/{uid}` and journal entries in `users/{uid}/entries/{id}`. Firebase Storage stores uploaded trip photos. Firebase Security Rules protect the data, so authenticated users can only read and write their own profiles, entries, and photos.
**Why is HMR not preserving my local component state?**
---
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
## Code Organization
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
src/
├── App.svelte Root component — mode switching & replay button
├── main.js Vite entry point
├── app.css Global CSS variables and resets
├── assets/ 14 static images (transport icons, profile, defaults)
└── lib/
├── firebase.js Firebase init (auth, Firestore, Storage)
├── auth/
│ ├── LoginOverlay.svelte Google sign-in dialog
│ ├── CountryPicker.svelte Home-country selection step
│ └── userStore.svelte.js Auth state & user profile store
├── layout/
│ ├── Layout.svelte App shell (auth guard + sidebar)
│ ├── TopBar.svelte Segmented nav (Map / Journal)
│ └── selection.svelte.js Reactive set of visited countries
├── stores/
│ └── entriesStore.svelte.js Firestore CRUD & reactive list
├── shared/
│ ├── cities.js Country→cities map
│ ├── countries.js Country names, IDs, flag emoji helpers
│ ├── SearchInput.svelte Autocomplete text input
│ └── types.js JSDoc type definitions
├── world-map/
│ ├── WorldMap.svelte D3 globe — visited countries, home marker, tooltips
│ ├── JourneyView.svelte Animated flight-path overlay + stats
│ ├── StatsPanel.svelte Trip statistics panel
│ └── continents.js Continent classification data
└── timeline/
├── detail/
│ ├── NewEntryForm.svelte Multi-step entry creation (3 steps)
│ ├── EditForm.svelte Multi-step entry editing (3 steps)
│ ├── StepNavbar.svelte Shared step-navigation top bar
│ ├── TripBasicInfo.svelte Step 1 form (country, cities, dates, transport)
│ ├── PhotoEditor.svelte Step 2 — upload & manage photos
│ ├── JournalDetail.svelte Full entry view with lightbox
│ └── DeleteConfirm.svelte Delete confirmation dialog
└── view/
├── TimelineView.svelte Sorted entry list with year groups
├── TimelineCard.svelte Entry card thumbnail
├── ShareCard.svelte Generated share image
└── SharePreview.svelte Share card preview modal
```
## Setup & Run
```bash
# Install
npm install
# Environment — create .env with your Firebase config:
# VITE_FIREBASE_API_KEY=...
# VITE_FIREBASE_AUTH_DOMAIN=...
# VITE_FIREBASE_PROJECT_ID=...
# VITE_FIREBASE_STORAGE_BUCKET=...
# VITE_FIREBASE_MESSAGING_SENDER_ID=...
# VITE_FIREBASE_APP_ID=...
# Run server
npm run dev
```
---
## Known Issues
- **Globe animation** — The D3 globe rotation animation during journey replay can be a little bit laggy.
- **Switching between animations** - after switching from map to globe animation, the old animation might be still visible for a moment. Restatarting the animation fixes the issue.
---
## Acknowledgments
- **Opencode & Claude Code (Sonnet 4.6) ** - researching, writing code, debuging... (https://opencode.ai/)
- **Gemini Nanobanana ** - generating logo and illustrations
- **D3.js docs** — help with using D3.js library (https://d3js.org/)
- **D3.js world tour example** — inspiration for adding animation (https://observablehq.com/@d3/world-tour)

View File

View File

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

12
firebase.json Normal file
View File

@@ -0,0 +1,12 @@
{
"storage": {
"rules": "storage.rules"
},
"hosting": {
"public": "dist",
"ignore": ["firebase.json", ".firebaserc"],
"rewrites": [
{ "source": "**", "destination": "/index.html" }
]
}
}

View File

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

1371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

BIN
public/DB-diagram.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -4,15 +4,40 @@
import CountryPicker from './lib/auth/CountryPicker.svelte';
import Layout from './lib/layout/Layout.svelte';
import WorldMap from './lib/world-map/WorldMap.svelte';
import JourneyView from './lib/world-map/JourneyView.svelte';
import StatsPanel from './lib/world-map/StatsPanel.svelte';
import TimelineView from './lib/TimelineView.svelte';
import TimelineView from './lib/timeline/view/TimelineView.svelte';
let screen = $state('worldmap');
let journeyActive = $state(false);
let journeyProgress = $state(null);
let inDetail = $state(false);
let pendingCountry = $state('');
let journeyMode = $state('map');
function onNavigate(s) {
screen = s;
}
function startJourney() {
journeyActive = true;
journeyProgress = null;
}
function endJourney() {
journeyActive = false;
journeyProgress = null;
}
function onJourneyProgress(p) {
journeyProgress = p;
}
function handleCountryClick(name) {
pendingCountry = name;
screen = 'timeline';
}
$effect(() => {
initAuth();
});
@@ -27,14 +52,26 @@
<span class="loading-text">Loading...</span>
</div>
{:else}
<Layout {screen} {onNavigate}>
<Layout {screen} {onNavigate} hideTopBar={inDetail}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area"><WorldMap /></div>
<StatsPanel />
<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>
{:else}
<TimelineView />
<TimelineView
onDetailChange={(v) => (inDetail = v)}
{pendingCountry}
onNewEntryClear={() => (pendingCountry = '')}
onGoToMap={() => { screen = 'worldmap'; }}
/>
{/if}
</Layout>
@@ -61,14 +98,48 @@
}
.worldmap-page {
flex: 1;
display: flex;
flex-direction: row;
width: 100%;
min-width: 0;
height: 100%;
position: relative;
}
.map-area {
flex: 1;
overflow: hidden;
position: relative;
}
.journey-play-btn {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
padding: 10px 22px;
border-radius: 20px;
border: none;
background: #8b5cf6;
color: #fff;
font-size: 14px;
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>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/airplane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/assets/bus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
src/assets/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

BIN
src/assets/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
src/assets/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

BIN
src/assets/ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
src/assets/train.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
src/assets/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,339 +0,0 @@
<script>
let { entry, onBack } = $props();
let photoIdx = $state(0);
function formatDate(/** @type {string} */ iso) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
}
function prev() {
photoIdx = (photoIdx - 1 + entry.photos.length) % entry.photos.length;
}
function next() {
photoIdx = (photoIdx + 1) % entry.photos.length;
}
function tripType(/** @type {string[] | undefined} */ companions) {
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'solo';
return 'friends';
}
function companionText(/** @type {string[] | undefined} */ companions) {
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'Solo';
return companions.join(', ');
}
const TRANSPORT_LABELS = {
plane: '✈️ Plane',
car: '🚗 Car',
train: '🚆 Train',
boat: '⛵ Boat',
bus: '🚌 Bus',
other: 'Other',
};
</script>
<article class="detail-page">
<button class="back-btn" onclick={onBack} aria-label="Back to timeline">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
{#if entry.photos?.length > 0}
<div class="hero-gallery">
<img
class="hero-img"
src={entry.photos[photoIdx]}
alt="{entry.title} photo {photoIdx + 1}"
loading="lazy"
/>
{#if entry.photos.length > 1}
<button class="arr left" onclick={prev} aria-label="Previous photo"></button>
<button class="arr right" onclick={next} aria-label="Next photo"></button>
<div class="thumb-strip">
{#each entry.photos as photo, i}
<button
class="thumb"
class:active={i === photoIdx}
onclick={() => (photoIdx = i)}
aria-label="Photo {i + 1}"
>
<img src={photo} alt="" />
</button>
{/each}
</div>
{/if}
<span class="photo-counter">{photoIdx + 1} / {entry.photos.length}</span>
</div>
{/if}
<div class="detail-content">
<div class="meta-row">
<span class="badge loc-badge">📍 {entry.city}, {entry.countryName}</span>
<span class="badge" class:trip-badge--solo={tripType(entry.companions) === 'solo'} class:trip-badge--friends={tripType(entry.companions) === 'friends'}>
{tripType(entry.companions) === 'solo' ? '🧍 Solo' : '👥 ' + companionText(entry.companions)}
</span>
{#if entry.transportation}
<span class="badge transport-badge">{TRANSPORT_LABELS[entry.transportation] || entry.transportation}</span>
{/if}
</div>
<h1 class="detail-title">{entry.title}</h1>
<div class="stats-row">
<div class="stat">
<span class="stat-label">Date</span>
<time class="stat-value" datetime={entry.date}>{formatDate(entry.date)}</time>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-label">Duration</span>
<span class="stat-value">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
</div>
</div>
<hr class="section-divider" />
<p class="detail-memo">{entry.memo}</p>
<hr class="section-divider" />
<div class="song-row">
<div class="song-icon-wrap" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
<circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
<div class="song-text">
<span class="song-label">Soundtrack</span>
<span class="song-name">{entry.song?.title || ''}</span>
<span class="song-artist">{entry.song?.artist || ''}</span>
</div>
</div>
</div>
</article>
<style>
.detail-page {
max-width: 680px;
margin: 0 auto;
padding: 32px 24px 80px;
font-family: var(--sans, system-ui, sans-serif);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: var(--text, #6b6375);
background: none;
border: none;
cursor: pointer;
padding: 6px 0;
margin-bottom: 28px;
transition: color 0.15s;
}
.back-btn:hover { color: var(--accent, #aa3bff); }
.hero-gallery {
position: relative;
border-radius: 16px;
overflow: hidden;
background: #000;
margin-bottom: 28px;
}
.hero-img {
width: 100%;
height: 380px;
object-fit: cover;
display: block;
}
.arr {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.45);
color: #fff;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
z-index: 2;
}
.arr:hover { background: rgba(0,0,0,0.7); }
.arr.left { left: 14px; }
.arr.right { right: 14px; }
.photo-counter {
position: absolute;
top: 14px;
right: 14px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: rgba(0,0,0,0.45);
padding: 3px 10px;
border-radius: 20px;
z-index: 2;
}
.thumb-strip {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 2;
}
.thumb {
width: 52px;
height: 36px;
border-radius: 6px;
overflow: hidden;
border: 2px solid transparent;
padding: 0;
cursor: pointer;
opacity: 0.65;
background: none;
transition: border-color 0.15s, opacity 0.15s;
}
.thumb.active { border-color: #fff; opacity: 1; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.detail-content { text-align: left; }
.meta-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.badge {
font-size: 12px;
font-weight: 500;
padding: 4px 10px;
border-radius: 20px;
}
.loc-badge {
background: var(--accent-bg, rgba(170,59,255,0.08));
color: var(--accent, #aa3bff);
}
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
.trip-badge--friends { background: rgba(59,130,246,0.12); color: #1d4ed8; }
.transport-badge {
background: rgba(16,185,129,0.12);
color: #059669;
}
.detail-title {
font-size: 28px;
font-weight: 700;
color: var(--text-h, #08060d);
margin: 0 0 20px;
letter-spacing: -0.6px;
line-height: 1.2;
}
.stats-row {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 24px;
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-label {
font-size: 11px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text, #6b6375);
}
.stat-value {
font-size: 15px;
font-weight: 600;
color: var(--text-h, #08060d);
}
.stat-divider {
width: 1px;
height: 32px;
background: var(--border, #e5e4e7);
}
.section-divider {
border: none;
border-top: 1px solid var(--border, #e5e4e7);
margin: 24px 0;
}
.detail-memo {
font-size: 16px;
line-height: 1.75;
color: var(--text, #6b6375);
margin: 0;
}
.song-row { display: flex; align-items: center; gap: 14px; }
.song-icon-wrap {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--accent-bg, rgba(170,59,255,0.08));
color: var(--accent, #aa3bff);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.song-text { display: flex; flex-direction: column; gap: 2px; }
.song-label {
font-size: 11px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text, #6b6375);
}
.song-name {
font-size: 15px;
font-weight: 600;
color: var(--text-h, #08060d);
}
.song-artist { font-size: 13px; color: var(--text, #6b6375); }
@media (max-width: 600px) {
.detail-page { padding: 24px 16px 60px; }
.hero-img { height: 260px; }
.detail-title { font-size: 22px; }
.thumb { width: 40px; height: 28px; }
}
</style>

View File

@@ -1,365 +0,0 @@
<script>
import { getEntries } from './stores/entriesStore.svelte.js';
import JournalDetail from './JournalDetail.svelte';
/** @type {Record<string, number>} */
let selected = $state(null);
/** @type {Record<string, number>} */
let photoIdx = $state({});
let entries = $derived(getEntries());
const sortOptions = [
{ value: 'date-desc', label: 'Newest First' },
{ value: 'date-asc', label: 'Oldest First' },
{ value: 'country-asc', label: 'Country A → Z' },
{ value: 'country-desc', label: 'Country Z → A' },
];
let sortKey = $state('date-desc');
let sortedEntries = $derived.by(() => {
const key = sortKey;
return [...entries].sort((a, b) => {
if (key === 'date-asc') return a.date.localeCompare(b.date);
if (key === 'date-desc') return b.date.localeCompare(a.date);
if (key === 'country-asc') return (a.countryName || '').localeCompare(b.countryName || '') || b.date.localeCompare(a.date);
if (key === 'country-desc') return (b.countryName || '').localeCompare(a.countryName || '') || b.date.localeCompare(a.date);
return 0;
});
});
function formatDate(/** @type {string} */ iso) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
}
function stepPhoto(/** @type {string} */ id, /** @type {number} */ total, /** @type {1|-1} */ dir, /** @type {Event} */ e) {
e.stopPropagation();
const cur = photoIdx[id] ?? 0;
photoIdx = { ...photoIdx, [id]: (cur + dir + total) % total };
}
function setPhoto(/** @type {string} */ id, /** @type {number} */ i, /** @type {Event} */ e) {
e.stopPropagation();
photoIdx = { ...photoIdx, [id]: i };
}
function tripType(/** @type {string[] | undefined} */ companions) {
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'solo';
return 'friends';
}
function companionText(/** @type {string[] | undefined} */ companions) {
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'Solo';
return companions.join(', ');
}
const TRANSPORT_LABELS = {
plane: '✈️ Plane',
car: '🚗 Car',
train: '🚆 Train',
boat: '⛵ Boat',
bus: '🚌 Bus',
other: 'Other',
};
</script>
{#if selected}
<JournalDetail entry={selected} onBack={() => (selected = null)} />
{:else}
<section class="timeline-view">
<header class="toolbar">
<div class="title-block">
<p class="eyebrow">Travel Journal</p>
<h1 class="page-title">My Journey</h1>
</div>
<div class="sort-control">
<label for="sort-select">Sort</label>
<select id="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
{#each sortOptions as opt}
<option value={opt.value} selected={opt.value === sortKey}>{opt.label}</option>
{/each}
</select>
</div>
</header>
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}
<ol class="v-list">
{#each sortedEntries as entry (entry.id)}
{@const idx = photoIdx[entry.id] ?? 0}
<li class="v-item">
<div class="v-dot" aria-hidden="true"></div>
<div class="v-entry-wrap">
<div class="above-card">
<time class="above-date" datetime={entry.date}>{formatDate(entry.date)}</time>
<span class="above-sep">·</span>
<span class="above-loc">{entry.city}, {entry.countryName}</span>
<span class="above-sep">·</span>
<span class="above-days">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
</div>
<div class="entry-card" role="button" tabindex="0"
onclick={() => (selected = entry)}
onkeydown={(e) => e.key === 'Enter' && (selected = entry)}>
{#if entry.photos?.length > 0}
<div class="gallery">
<img class="gallery-main" src={entry.photos[idx]}
alt="{entry.title} photo {idx + 1}" loading="lazy" />
{#if entry.photos.length > 1}
<button class="gallery-arrow left"
onclick={(e) => stepPhoto(entry.id, entry.photos.length, -1, e)}
aria-label="Previous photo"></button>
<button class="gallery-arrow right"
onclick={(e) => stepPhoto(entry.id, entry.photos.length, 1, e)}
aria-label="Next photo"></button>
<div class="gallery-dots">
{#each entry.photos as _, i}
<button class="gallery-pip" class:active={i === idx}
onclick={(e) => setPhoto(entry.id, i, e)}
aria-label="Photo {i + 1}"></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="entry-body">
<h2 class="entry-title">{entry.title}</h2>
{#if entry.memo}
<p class="entry-memo">{entry.memo}</p>
{/if}
<div class="entry-song">
{#if entry.transportation && TRANSPORT_LABELS[entry.transportation]}
<span class="transport-badge">{TRANSPORT_LABELS[entry.transportation]}</span>
{/if}
<svg class="song-icon" width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
<circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="song-title">{entry.song?.title || ''}</span>
<span class="song-sep">·</span>
<span class="song-artist">{entry.song?.artist || ''}</span>
<span class="trip-badge" class:trip-badge--solo={tripType(entry.companions) === 'solo'} class:trip-badge--friends={tripType(entry.companions) === 'friends'}>
{companionText(entry.companions)}
</span>
</div>
</div>
</div>
</div>
</li>
{/each}
</ol>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
</footer>
</section>
{/if}
<style>
.timeline-view {
max-width: 600px;
margin: 0 auto;
padding: 48px 24px 64px;
font-family: var(--sans, system-ui, sans-serif);
}
.toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 48px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border, #e5e4e7);
}
.eyebrow {
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent, #aa3bff);
margin: 0 0 6px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: var(--text-h, #08060d);
margin: 0;
letter-spacing: -0.8px;
}
.sort-control { display: flex; align-items: center; gap: 8px; }
.sort-control label { font-size: 13px; color: var(--text, #6b6375); }
select {
font-size: 13px;
padding: 7px 28px 7px 10px;
border: 1px solid var(--border, #e5e4e7);
border-radius: 8px;
background: var(--bg, #fff);
color: var(--text-h, #08060d);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b6375' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
select:focus { outline: 2px solid var(--accent, #aa3bff); outline-offset: 2px; }
.above-card {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.above-date { font-size: 12px; font-weight: 500; color: var(--text-h, #08060d); }
.above-loc, .above-days { font-size: 12px; color: var(--text, #6b6375); }
.above-sep { font-size: 11px; color: var(--border, #c8c6cc); user-select: none; }
.entry-card {
display: block;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border, #e5e4e7);
border-radius: 14px;
overflow: hidden;
background: var(--bg, #fff);
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s;
text-align: left;
}
.entry-card:hover {
box-shadow: 0 6px 24px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.entry-body { padding: 14px 18px 18px; }
.entry-song { gap: 5px; }
.entry-song .trip-badge { margin-left: auto; flex-shrink: 0; }
.trip-badge {
display: inline-block;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 20px;
}
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
.trip-badge--friends { background: rgba(59,130,246,0.12); color: #1d4ed8; }
.transport-badge {
font-size: 11px;
font-weight: 500;
color: #6b6375;
flex-shrink: 0;
}
.entry-title {
font-size: 16px;
font-weight: 600;
color: var(--text-h, #08060d);
margin: 0 0 6px;
letter-spacing: -0.2px;
}
.entry-memo {
font-size: 13px;
line-height: 1.6;
color: var(--text, #6b6375);
margin: 0 0 10px;
}
.entry-song {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text, #6b6375);
padding-top: 10px;
border-top: 1px solid var(--border, #e5e4e7);
}
.song-icon { flex-shrink: 0; color: var(--accent, #aa3bff); }
.song-title { font-weight: 500; color: var(--text-h, #08060d); }
.song-sep { opacity: 0.35; }
.gallery { position: relative; overflow: hidden; background: #000; }
.gallery-main { width: 100%; height: 220px; object-fit: cover; display: block; }
.gallery-arrow {
position: absolute; top: 50%; transform: translateY(-50%);
background: rgba(0,0,0,0.45); color: #fff;
border: none; width: 32px; height: 32px; border-radius: 50%;
font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s; z-index: 2;
}
.gallery-arrow:hover { background: rgba(0,0,0,0.7); }
.gallery-arrow.left { left: 10px; }
.gallery-arrow.right { right: 10px; }
.gallery-dots {
position: absolute; bottom: 8px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 5px; z-index: 2;
}
.gallery-pip {
width: 6px; height: 6px; border-radius: 50%;
border: none; background: rgba(255,255,255,0.5);
cursor: pointer; padding: 0;
transition: background 0.15s, transform 0.15s;
}
.gallery-pip.active { background: #fff; transform: scale(1.3); }
.v-list { list-style: none; padding: 0; margin: 0; position: relative; }
.v-list::before {
content: '';
position: absolute; left: 10px; top: 6px; bottom: 6px;
width: 2px; background: var(--border, #e5e4e7); border-radius: 1px;
}
.v-item { display: flex; gap: 24px; align-items: flex-start; padding-bottom: 36px; }
.v-item:last-child { padding-bottom: 0; }
.v-dot {
flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%;
background: var(--accent, #aa3bff);
border: 3px solid var(--bg, #fff);
box-shadow: 0 0 0 2px var(--accent, #aa3bff);
margin-top: 28px; z-index: 1;
}
.v-entry-wrap { flex: 1; display: flex; flex-direction: column; }
.page-footer {
margin-top: 40px;
text-align: center;
font-size: 13px;
color: var(--text, #6b6375);
padding-top: 24px;
border-top: 1px solid var(--border, #e5e4e7);
}
.empty { text-align: center; color: var(--text, #6b6375); padding: 80px 0; }
@media (max-width: 600px) {
.timeline-view { padding: 32px 16px 48px; }
.page-title { font-size: 26px; }
.v-list::before { left: 8px; }
.v-dot { width: 18px; height: 18px; }
.v-item { gap: 16px; }
.gallery-main { height: 180px; }
}
</style>

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { auth, db, googleProvider } from '../firebase.js';
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
import { initSelectionListener } from '../layout/selection.svelte.js';
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
let _initialized = false;
@@ -48,7 +47,6 @@ export function initAuth() {
onAuthStateChanged(auth, async (fbUser) => {
if (fbUser) {
user = fbUser;
initSelectionListener(fbUser.uid);
initEntriesListener(fbUser.uid);
const docRef = doc(db, 'users', fbUser.uid);
const docSnap = await getDoc(docRef);

View File

@@ -1,18 +1,19 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
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,
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,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);
export const googleProvider = new GoogleAuthProvider();

View File

@@ -1,19 +0,0 @@
<footer class="footer">
© 2026 Tomas Horsky &amp; Haeri Kim
</footer>
<style>
.footer {
height: 32px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 24px;
background: #334155;
font: 15px/1.6 sans-serif;
color: #cbd5e1;
position: relative;
z-index: 10;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

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

View File

@@ -1,6 +1,5 @@
<script>
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
let { screen, onNavigate } = $props();
let user = $derived(getUser());
@@ -20,8 +19,9 @@
<div class="topbar">
<div class="left">
<img src="/logo.png" alt="Logo" class="logo" />
<span class="app-name">Map Journal</span>
<div class="brand">
<span class="app-name">Journi</span>
</div>
</div>
<div class="center">
@@ -30,8 +30,8 @@
class="slider"
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
></div>
<button onclick={() => onNavigate('worldmap')}>Worldmap</button>
<button onclick={() => onNavigate('timeline')}>Timeline</button>
<button class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Worldmap</button>
<button class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button>
</div>
</div>
@@ -66,15 +66,16 @@
<style>
.topbar {
height: 64px;
height: 52px;
display: flex;
align-items: center;
padding: 0 24px;
background: #1e2937;
padding: 0 32px;
gap: 16px;
position: relative;
z-index: 10;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.left {
@@ -83,18 +84,19 @@
gap: 10px;
}
.logo {
width: 75px;
height: 75px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
.brand {
display: flex;
align-items: center;
gap: 4px;
}
.app-name {
font: 700 20px/1.2 sans-serif;
color: #f1f5f9;
font-family: var(--heading);
font-size: 22px;
font-weight: 600;
color: var(--text-h);
white-space: nowrap;
letter-spacing: -0.5px;
}
.center {
@@ -106,10 +108,10 @@
.segmented {
position: relative;
display: flex;
background: rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 9999px;
padding: 4px;
width: 300px;
}
.slider {
@@ -118,8 +120,9 @@
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: #fff;
border-radius: 999px;
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;
}
@@ -128,14 +131,21 @@
position: relative;
z-index: 1;
flex: 1;
padding: 10px 20px;
padding: 6px 24px;
border: none;
background: none;
cursor: pointer;
font: 500 16px/1.4 sans-serif;
color: #cbd5e1;
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
color: var(--text);
letter-spacing: 0.01em;
transition: color 0.2s ease;
}
.segmented button.active {
color: #fff;
}
.right {
display: flex;
align-items: center;
@@ -155,8 +165,8 @@
}
.avatar {
width: 45px;
height: 45px;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
@@ -166,12 +176,12 @@
position: absolute;
top: calc(100% + 8px);
right: 0;
background: #1e2937;
border: 1px solid #334155;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 0;
min-width: 200px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow);
z-index: 50;
}
@@ -184,17 +194,17 @@
.menu-name {
font: 600 14px/1.3 sans-serif;
color: #f1f5f9;
color: var(--text-h);
}
.menu-email {
font: 400 12px/1.3 sans-serif;
color: #94a3b8;
color: var(--text-sub);
}
.divider {
height: 1px;
background: #334155;
background: var(--border);
margin: 6px 0;
}
@@ -205,13 +215,13 @@
background: none;
text-align: left;
font: 400 14px/1.4 sans-serif;
color: #fca5a5;
color: #ef4444;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--bg-subtle);
}
.backdrop {

View File

@@ -1,49 +1,24 @@
import { db } from '../firebase.js';
import { doc, onSnapshot, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
import { journals } from '../stores/entriesStore.svelte.js';
import { nameToId } from '../shared/countries.js';
import { getUserProfile } from '../auth/userStore.svelte.js';
let selected = $state(new Set());
let totalCountries = $state(0);
let homeCountryCode = $state(null);
let _uid = null;
let _unsubscribe = null;
let flashing = $state(new Set());
export function initSelectionListener(uid) {
if (_unsubscribe) _unsubscribe();
_uid = uid;
const userRef = doc(db, 'users', uid);
_unsubscribe = onSnapshot(userRef, (snap) => {
if (snap.exists()) {
const codes = snap.data().visitedCountries || [];
selected = new Set(codes);
homeCountryCode = snap.data().homeCountryCode || null;
}
});
}
export function toggle(id) {
if (!_uid) return;
const was = selected.has(id);
const next = new Set(selected);
if (was) {
next.delete(id);
} else {
next.add(id);
journals.subscribe((entries) => {
const ids = new Set();
for (const e of entries) {
const id = nameToId[e.location?.country];
if (id) ids.add(id);
}
selected = next;
const userRef = doc(db, 'users', _uid);
if (was) {
updateDoc(userRef, { visitedCountries: arrayRemove(id) });
} else {
updateDoc(userRef, { visitedCountries: arrayUnion(id) });
const profile = getUserProfile();
if (profile?.homeCountry) {
const homeId = nameToId[profile.homeCountry];
if (homeId) ids.add(homeId);
}
}
export function clearAll() {
if (!_uid) return;
selected = new Set();
const userRef = doc(db, 'users', _uid);
updateDoc(userRef, { visitedCountries: [] });
}
selected = ids;
});
export function getSelected() {
return selected;
@@ -57,6 +32,15 @@ export function getTotalCount() {
return totalCountries;
}
export function getHomeCountryCode() {
return homeCountryCode;
export function getFlashing() {
return flashing;
}
export function flashCountry(countryName) {
const id = nameToId[countryName];
if (!id) return;
flashing = new Set([...flashing, id]);
setTimeout(() => {
flashing = new Set([...flashing].filter(x => x !== id));
}, 1600);
}

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -1,118 +0,0 @@
import { writable } from 'svelte/store';
/**
* @typedef {{
* id: string,
* title: string,
* date: string,
* location: { country: string, city: string },
* photos: string[],
* song: { title: string, artist: string },
* 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',
],
song: { title: 'Tokyo', artist: 'Imagine Dragons' },
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',
],
song: { title: 'Spirited Away Suite', artist: 'Joe Hisaishi' },
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',
],
song: { title: 'La Vie en Rose', artist: 'Édith Piaf' },
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',
],
song: { title: 'Spain', artist: 'Chick Corea' },
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',
],
song: { title: 'New York, New York', artist: 'Frank Sinatra' },
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',
],
song: { title: 'Elephant', artist: 'Tame Impala' },
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));
}

View File

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

View File

@@ -0,0 +1,217 @@
<script>
import { addEntry, updateEntry } from '../../stores/entriesStore.svelte.js';
import PhotoEditor from './PhotoEditor.svelte';
import StepNav from './StepNavbar.svelte';
import TripBasicInfo from './TripBasicInfo.svelte';
/**
* 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();
// svelte-ignore state_referenced_locally
let isNew = !entry;
// svelte-ignore state_referenced_locally
let cities = $state([...(entry?.location.cities ?? [])]);
// svelte-ignore state_referenced_locally
let country = $state(entry?.location.country ?? initialCountry);
// svelte-ignore state_referenced_locally
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
// svelte-ignore state_referenced_locally
let days = $state(String(entry?.days ?? ''));
// svelte-ignore state_referenced_locally
let tripType = $state(entry?.tripType ?? '');
// svelte-ignore state_referenced_locally
let photos = $state([...(entry?.photos ?? [])]);
// svelte-ignore state_referenced_locally
let memo = $state(entry?.memo ?? '');
// svelte-ignore state_referenced_locally
let transport = $state(entry?.transport ?? '');
let step = $state(1);
let errors = $state({
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
});
function clearErrors() {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
}
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;
}
}
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);
}
}
let next = $derived(step < 3 ? nextStep : save);
</script>
<div class="layout">
<StepNav {step} totalSteps={3} onback={prevStep} onnext={next} />
<div class="scroll">
<div class="form">
{#if step === 1}
<TripBasicInfo
bind:country bind:cities
bind:date bind:days bind:tripType bind:transport
bind:errors {isNew}
/>
{: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);
}
.scroll { flex: 1; overflow-y: auto; }
.form {
max-width: 560px;
margin: 0 auto;
padding: 36px 48px 80px;
display: flex;
flex-direction: column;
gap: 18px;
}
.step-title {
font-size: 20px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.step-sub {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
margin: -10px 0 4px;
}
.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);
}
.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; }
</style>

View File

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

View File

@@ -0,0 +1,404 @@
<script>
import { journals, addEntry } from '../../stores/entriesStore.svelte.js';
import { get } from 'svelte/store';
import { flashCountry } from '../../layout/selection.svelte.js';
import { countryNames } from '../../shared/countries.js';
import { ALL_CITIES } from '../../shared/cities.js';
import SearchInput from '../../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
import StepNav from './StepNavbar.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('');
let date = $state(new Date().toISOString().slice(0, 10));
$effect(() => {
country = initialCountry;
});
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([
...(ALL_CITIES[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 addEntry({
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.';
}
}
let next = $derived(step < 3 ? nextStep : save);
</script>
<div class="layout">
<StepNav {step} onback={prevStep} onnext={next} {saving} saveLabel="Save trip" {saveError} />
<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">
<span class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></span>
<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">
<span class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></span>
<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);
}
/* 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>

View File

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

View File

@@ -0,0 +1,100 @@
<script>
let { step, totalSteps = 3, onback, onnext, saving = false, saveLabel = 'Save changes', saveError = '' } = $props();
</script>
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={onback}>
<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 Array(totalSteps) as _, i}
<div class="step-dot" class:active={step === i + 1} class:done={step > i + 1}></div>
{/each}
</div>
<div class="topbar-right">
<button class="save-btn" onclick={onnext} disabled={saving}>
{#if saving}
Saving…
{:else if step < totalSteps}
Next
{:else}
{saveLabel}
{/if}
</button>
{#if saveError}<span class="save-err">{saveError}</span>{/if}
</div>
</header>
<style>
.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; }
.save-err { font-size: 12px; color: #dc2626; white-space: nowrap; }
</style>

View File

@@ -0,0 +1,188 @@
<script>
import { countryNames } from '../../shared/countries.js';
import { getCitiesForCountry, ALL_CITIES } from '../../shared/cities.js';
import { getEntries } from '../../stores/entriesStore.svelte.js';
import SearchInput from '../../shared/SearchInput.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 {
country = $bindable(''),
cities = $bindable([]),
date = $bindable(''),
days = $bindable(''),
tripType = $bindable(''),
transport = $bindable(''),
errors = $bindable({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' }),
isNew = false,
} = $props();
let cityInput = $state('');
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 },
];
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);
}
</script>
<h1 class="page-headline">
{isNew ? 'Journal your trip!' : 'Edit your trip'}
</h1>
<div class="row">
<div class="field">
<label class="label" for="tbi-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
<SearchInput id="tbi-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="tbi-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
<SearchInput id="tbi-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="tbi-date">When did you <span class="kw">arrive</span>? <span class="req">*</span></label>
<input id="tbi-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="tbi-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
<input id="tbi-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">
<span class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></span>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="tbi-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">
<span class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></span>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
<input type="radio" name="tbi-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>
<style>
.page-headline {
font-size: 28px;
font-weight: 500;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0 0 4px;
}
.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-h);
}
.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: 10px; flex-wrap: wrap; }
.toggle-opt {
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 14px; font-weight: 400; color: var(--text);
padding: 12px 14px; border-radius: 10px;
border: 1px solid var(--border);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
background: var(--bg-subtle);
white-space: nowrap;
}
.toggle-opt input { display: none; }
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.transport-opt { flex-direction: column; gap: 6px; padding: 16px 10px; }
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 300; color: var(--accent);
background: var(--accent-bg); border: 1px solid var(--accent-border);
border-radius: 20px; padding: 3px 10px 3px 12px;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
</style>

View File

@@ -0,0 +1,444 @@
<script>
import { toPng } from 'html-to-image';
import { getTotalCount } from '../../layout/selection.svelte.js';
import profileImg from '../../../assets/profile.png';
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClose: () => void }} */
let { entries, onClose } = $props();
let cardEl = $state(null);
let downloading = $state(false);
const continentMap = {
'Japan': 'Asia', 'South Korea': 'Asia', 'China': 'Asia', 'Thailand': 'Asia',
'Vietnam': 'Asia', 'Indonesia': 'Asia', 'Malaysia': 'Asia', 'Singapore': 'Asia',
'India': 'Asia', 'Taiwan': 'Asia', 'Philippines': 'Asia', 'Cambodia': 'Asia',
'Nepal': 'Asia', 'Bangladesh': 'Asia',
'France': 'Europe', 'Spain': 'Europe', 'Italy': 'Europe', 'Germany': 'Europe',
'UK': 'Europe', 'Netherlands': 'Europe', 'Portugal': 'Europe', 'Greece': 'Europe',
'Sweden': 'Europe', 'Norway': 'Europe', 'Denmark': 'Europe', 'Finland': 'Europe',
'Switzerland': 'Europe', 'Austria': 'Europe', 'Belgium': 'Europe', 'Poland': 'Europe',
'Czech Republic': 'Europe', 'Hungary': 'Europe', 'Croatia': 'Europe', 'Turkey': 'Europe',
'USA': 'N. America', 'Canada': 'N. America', 'Mexico': 'N. America',
'Brazil': 'S. America', 'Argentina': 'S. America', 'Chile': 'S. America',
'Peru': 'S. America', 'Colombia': 'S. America',
'Australia': 'Oceania', 'New Zealand': 'Oceania',
'Morocco': 'Africa', 'Egypt': 'Africa', 'Kenya': 'Africa', 'South Africa': 'Africa',
};
const flightHoursMap = {
'Japan': 13, 'South Korea': 13, 'China': 12, 'Thailand': 11, 'Vietnam': 12,
'Indonesia': 14, 'Malaysia': 11, 'Singapore': 11, 'India': 9, 'Taiwan': 13,
'France': 9, 'Spain': 10, 'Italy': 10, 'Germany': 9, 'UK': 8, 'Netherlands': 9,
'Portugal': 9, 'Greece': 11, 'Sweden': 10, 'Norway': 9, 'Switzerland': 9,
'Turkey': 11, 'Austria': 10, 'Belgium': 9, 'Poland': 10,
'USA': 14, 'Canada': 13, 'Mexico': 13,
'Brazil': 10, 'Argentina': 12, 'Chile': 13,
'Australia': 20, 'New Zealand': 23,
'Morocco': 10, 'Egypt': 12, 'Kenya': 14, 'South Africa': 16,
};
let stats = $derived.by(() => {
if (entries.length === 0) return null;
const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))];
const cities = [...new Set(entries.flatMap(e => e.location.cities))];
// Continent days
const contDays = {};
for (const e of entries) {
const cont = continentMap[e.location.country] ?? 'Other';
contDays[cont] = (contDays[cont] ?? 0) + e.days;
}
const topContinent = Object.entries(contDays).sort((a, b) => b[1] - a[1])[0];
// Longest trip
const longest = [...entries].sort((a, b) => b.days - a.days)[0];
// Date range
const dates = entries.map(e => new Date(e.date));
const firstDate = new Date(Math.min(...dates));
const lastDate = new Date(Math.max(...dates));
const spanDays = Math.round((lastDate - firstDate) / 86400000);
const spanMonths = Math.round(spanDays / 30);
// Flight hours estimate
const flightTrips = entries.filter(e => e.transport === 'flight');
const flightHrs = flightTrips.reduce((s, e) => s + (flightHoursMap[e.location.country] ?? 10), 0);
// Solo vs friends
const soloCount = entries.filter(e => e.tripType === 'solo').length;
const friendCount = entries.filter(e => e.tripType === 'friends').length;
const familyCount = entries.filter(e => e.tripType === 'family').length;
// Most visited country
const countryCounts = {};
for (const e of entries) countryCounts[e.location.country] = (countryCounts[e.location.country] ?? 0) + 1;
const favCountry = Object.entries(countryCounts).sort((a, b) => b[1] - a[1])[0];
const yearStart = firstDate.getFullYear();
const yearEnd = lastDate.getFullYear();
return {
totalDays, countries, cities, contDays, topContinent,
longest, flightHrs, soloCount, friendCount, familyCount,
favCountry, spanMonths, yearStart, yearEnd,
};
});
async function getFontDataUrl() {
// Get the actual woff2 URL the browser resolved for Bricolage Grotesque
for (const font of document.fonts) {
if (font.family.includes('Bricolage')) {
// font.status === 'loaded' means the browser has it
await font.load();
}
}
// Fetch the Google Fonts CSS to extract the real woff2 URL
const cssRes = await fetch(
'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500&display=swap',
{ headers: { 'User-Agent': 'Mozilla/5.0' } }
);
const css = await cssRes.text();
const match = css.match(/url\((https:\/\/fonts\.gstatic\.com[^)]+\.woff2)\)/);
if (!match) return null;
const fontRes = await fetch(match[1]);
const blob = await fontRes.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
async function download() {
if (!cardEl) return;
downloading = true;
try {
await document.fonts.ready;
let fontFaceRule = '';
try {
const fontData = await getFontDataUrl();
if (fontData) {
fontFaceRule = `@font-face { font-family: 'Bricolage Grotesque'; src: url('${fontData}') format('woff2'); font-weight: 100 900; font-style: normal; }`;
}
} catch {}
// Inject font as a real <style> inside the card so html-to-image clones it
let injected = null;
if (fontFaceRule) {
injected = document.createElement('style');
injected.textContent = fontFaceRule;
cardEl.prepend(injected);
}
const opts = { pixelRatio: 3 };
await toPng(cardEl, opts); // first pass loads resources
const dataUrl = await toPng(cardEl, opts);
if (injected) injected.remove();
const a = document.createElement('a');
a.download = 'my-journey.png';
a.href = dataUrl;
a.click();
} finally {
downloading = false;
}
}
function fmtYear(y1, y2) {
return y1 === y2 ? `${y1}` : `${y1} ${y2}`;
}
</script>
<!-- Overlay -->
<div class="overlay" role="dialog" aria-modal="true">
<div class="overlay-inner">
<!-- Controls -->
<div class="controls">
<button class="ctrl-btn" onclick={onClose}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
Close
</button>
<button class="ctrl-btn ctrl-btn--primary" onclick={download} disabled={downloading}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
{downloading ? 'Saving…' : 'Save as PNG'}
</button>
</div>
<!-- The card (Instagram story 9:16) -->
{#if stats}
<div class="card-wrap">
<div class="card" bind:this={cardEl}>
<!-- Background layers -->
<div class="bg-gradient"></div>
<div class="bg-grid"></div>
<!-- Header -->
<div class="card-header">
<span class="card-brand">JOURNI</span>
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
</div>
<!-- Profile -->
<div class="profile-wrap">
<img class="profile-img" src={profileImg} alt="profile" />
</div>
<!-- Hero stat -->
<div class="hero">
<p class="hero-pre">You've colored</p>
<p class="big-num">{getTotalCount() > 0 ? Math.round((stats.countries.length / getTotalCount()) * 100) : 0}%</p>
<p class="hero-post">of the world map.</p>
</div>
<!-- Fun facts -->
<div class="facts">
{#if stats.topContinent}
<div class="fact">
<span class="fact-icon">🌏</span>
<span class="fact-text">Spent <strong>{stats.topContinent[1]} days</strong> in {stats.topContinent[0]}</span>
</div>
{/if}
{#if stats.longest}
<div class="fact">
<span class="fact-icon">📍</span>
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.cities.join(', ')}</span>
</div>
{/if}
{#if stats.favCountry && stats.favCountry[1] > 1}
<div class="fact">
<span class="fact-icon">❤️</span>
<span class="fact-text">Kept coming back to <strong>{stats.favCountry[0]}</strong></span>
</div>
{/if}
<div class="fact">
<span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</span>
<span class="fact-text">{stats.soloCount} solo{stats.friendCount > 0 ? ` · ${stats.friendCount} with friends` : ''}{stats.familyCount > 0 ? ` · ${stats.familyCount} with family` : ''}</span>
</div>
</div>
<!-- Footer -->
<div class="card-footer">
<span>journi</span>
</div>
</div>
</div>
{/if}
</div>
</div>
<style>
/* ── Overlay ── */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
backdrop-filter: blur(4px);
}
.overlay-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
height: 100%;
overflow-y: auto;
}
/* ── Controls ── */
.controls {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.ctrl-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--sans);
font-size: 13px;
font-weight: 400;
padding: 8px 18px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
.ctrl-btn:hover { background: rgba(255,255,255,0.18); }
.ctrl-btn--primary {
background: #7c3aed;
border-color: #7c3aed;
}
.ctrl-btn--primary:hover { background: #6d28d9; }
.ctrl-btn:disabled { opacity: 0.6; cursor: default; }
/* ── Card wrap (scrollable preview) ── */
.card-wrap {
flex-shrink: 0;
}
/* ── Card itself (Instagram story 9:16 → 360×640 preview) ── */
.card {
width: 360px;
min-height: 640px;
border-radius: 20px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
padding: 32px 28px 24px;
gap: 0;
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
background: #0f0a1e;
color: #fff;
/* continent color vars */
--cont-asia: #f87171;
--cont-europe: #818cf8;
--cont-africa: #fb923c;
--cont-namerica: #4ade80;
--cont-n-america: #4ade80;
--cont-samerica: #fbbf24;
--cont-s-america: #fbbf24;
--cont-oceania: #c084fc;
}
/* Backgrounds */
.bg-gradient {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 40% at 80% 10%, rgba(124,58,237,0.35) 0%, transparent 70%),
radial-gradient(ellipse 50% 50% at 10% 80%, rgba(99,102,241,0.25) 0%, transparent 60%);
pointer-events: none;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
}
/* Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 36px;
position: relative;
z-index: 1;
}
.card-brand {
font-size: 9px;
font-weight: 500;
letter-spacing: 0.22em;
color: #a5b4fc;
}
.card-year {
font-size: 11px;
font-weight: 300;
color: rgba(255,255,255,0.4);
letter-spacing: 0.06em;
}
/* Profile */
.profile-wrap {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.profile-img {
width: 192px;
height: 192px;
border-radius: 50%;
object-fit: cover;
}
/* Hero */
.hero {
position: relative;
z-index: 1;
margin-bottom: 28px;
}
.hero-pre, .hero-post {
font-size: 16px;
font-weight: 300;
color: rgba(255,255,255,0.6);
letter-spacing: 0.04em;
margin: 0;
}
.big-num {
font-size: 88px;
font-weight: 500;
color: #fff;
letter-spacing: -4px;
line-height: 1;
margin: 8px 0;
}
/* Facts */
.facts {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.fact {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
padding: 10px 14px;
}
.fact-icon { font-size: 16px; flex-shrink: 0; }
.fact-text {
font-size: 12px;
font-weight: 300;
color: rgba(255,255,255,0.7);
line-height: 1.4;
}
.fact-text strong { color: #fff; font-weight: 400; }
/* Footer */
.card-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid rgba(255,255,255,0.08);
font-size: 9px;
font-weight: 300;
color: rgba(255,255,255,0.25);
letter-spacing: 0.12em;
text-transform: uppercase;
position: relative;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,230 @@
<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;
}
</style>

View File

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

View File

@@ -0,0 +1,292 @@
<script>
import { getEntries } from '../../stores/entriesStore.svelte.js';
import TimelineCard from './TimelineCard.svelte';
import JournalDetail from '../detail/JournalDetail.svelte';
import EditForm from '../detail/EditForm.svelte';
import NewEntryForm from '../detail/NewEntryForm.svelte';
import ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte';
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {}, onGoToMap = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null));
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
let showShare = $state(false);
let newEntryCountry = $state('');
// When App passes a country from the map, capture it locally before clearing
$effect(() => {
if (pendingCountry) {
newEntryCountry = pendingCountry;
selectedId = null;
view = 'new';
onNewEntryClear();
}
});
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
let entries = $derived(getEntries());
let sortKey = $state('date-desc');
let sortedEntries = $state(/** @type {typeof entries} */([]));
$effect(() => {
const key = sortKey;
sortedEntries = [...entries].sort((a, b) => {
if (key === 'date-asc') return a.date.localeCompare(b.date);
if (key === 'date-desc') return b.date.localeCompare(a.date);
if (key === 'country-asc') return a.location.country.localeCompare(b.location.country) || b.date.localeCompare(a.date);
if (key === 'country-desc') return b.location.country.localeCompare(a.location.country) || b.date.localeCompare(a.date);
return 0;
});
});
function getYear(iso) {
return new Date(iso).getFullYear();
}
</script>
<div class="journal-page">
{#if view === 'new'}
<div class="detail-scroll">
<NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} onSaved={() => { onGoToMap(); }} />
</div>
{:else if view === 'edit' && selected}
<div class="detail-scroll">
<EditForm entry={selected} onBack={() => { view = 'detail'; }} />
</div>
{:else if view === 'detail' && selected}
<div class="detail-scroll">
<JournalDetail
entry={selected}
onBack={() => { selectedId = null; view = 'list'; onDetailChange(false); }}
onEdit={() => { view = 'edit'; }}
/>
</div>
{:else}
<div class="list-view">
<div class="page-header">
<h1 class="page-title">My Journey</h1>
<button class="new-btn" onclick={() => { view = 'new'; }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>
Add trip
</button>
</div>
<div class="two-col">
<div class="left-col">
{#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" bind:value={sortKey}>
<option value="date-desc">Newest first</option>
<option value="date-asc">Oldest first</option>
<option value="country-asc">Country A → Z</option>
<option value="country-desc">Country Z → A</option>
</select>
</div>
<ol class="v-list">
{#each sortedEntries as entry, i (entry.id)}
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
<li class="year-marker" aria-hidden="true">
<span class="year-label">{getYear(entry.date)}</span>
</li>
{/if}
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
{/each}
</ol>
{/if}
<footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
</footer>
</div>
{#if sortedEntries.length > 0}
<div class="right-col">
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if showShare}
<ShareCard entries={sortedEntries} onClose={() => (showShare = false)} />
{/if}
<style>
.journal-page {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
height: 100%;
overflow: hidden;
}
/* ── List view wrapper (scrollable) ── */
.list-view {
flex: 1;
overflow-y: auto;
padding: 48px 0 80px;
box-sizing: border-box;
min-width: 0;
}
.page-header,
.two-col {
max-width: 960px;
margin-left: auto;
margin-right: auto;
padding-left: 48px;
padding-right: 48px;
}
/* ── Two-column below header ── */
.two-col {
display: flex;
flex-direction: row;
gap: 32px;
align-items: flex-start;
margin-top: 24px;
}
.left-col {
flex: 1;
min-width: 0;
}
.right-col {
width: 260px;
flex-shrink: 0;
position: sticky;
top: 0;
}
@media (max-width: 900px) {
.right-col { display: none; }
}
@media (max-width: 760px) {
.list-view { padding: 32px 24px 60px; }
}
/* ── Detail view ── */
.detail-scroll {
flex: 1;
overflow-y: auto;
background: var(--bg);
}
/* ── Timeline list ── */
.v-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
/* Year marker */
.year-marker {
padding: 24px 0 14px;
}
.year-label {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.04em;
color: #7c3aed;
border-left: 3px solid #7c3aed;
padding-left: 12px;
}
.page-footer {
margin-top: 56px;
text-align: center;
font-size: 11px;
font-weight: 300;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-sub);
padding-top: 24px;
border-top: 1px solid var(--border);
}
.empty { text-align: center; color: var(--text-sub); padding: 80px 0; }
.sort-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
padding-top: 20px;
}
.sort-label {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-sub);
}
.sort-select {
font-family: var(--sans);
font-size: 11px;
font-weight: 300;
letter-spacing: 0.04em;
padding: 4px 24px 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-subtle);
color: var(--text);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2352525b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.sort-select:hover { border-color: var(--border-bright); color: var(--text-h); }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: var(--text-xl);
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0;
}
.new-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
</style>

View File

@@ -0,0 +1,412 @@
<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/entriesStore.svelte.js';
import { getUserProfile } from '../auth/userStore.svelte.js';
import { nameToId } from '../shared/countries.js';
import airplaneImg from '../../assets/airplane-animation.png';
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
const HOME_CODE = $derived(nameToId[getUserProfile()?.homeCountry] ?? null);
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) => b.date.localeCompare(a.date));
const trips = 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);
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>

View File

@@ -9,9 +9,9 @@
'Europe': '#3b82f6',
'Asia': '#ef4444',
'Africa': '#f97316',
'N. America': '#22c55e',
'N. America': '#ec4899',
'S. America': '#eab308',
'Oceania': '#a855f7'
'Oceania': '#a16207'
};
const countryNameById = $derived.by(() => {
@@ -98,13 +98,11 @@
<div class="panel-content">
<h2 class="headline">your statistics</h2>
<span class="bar-label">visited countries</span>
<div class="total-bar-wrap">
<div class="total-bar-bg">
<div class="total-bar-fill" style="width: {pct}%"></div>
</div>
<span class="total-bar-text">{total} / {grandTotal}</span>
<div class="total-bar-bg">
<div class="total-bar-fill" style="width: {pct}%"></div>
<span class="bar-pct">{pct}%</span>
</div>
<span class="total-bar-text">{total} / {grandTotal} countries visited</span>
<div class="divider"></div>
@@ -130,19 +128,19 @@
<div class="donut-wrap">
{#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}
<g class="seg-group">
<path d={seg.path} fill={seg.color} />
<text x={seg.lx} y={seg.ly} text-anchor="middle" dominant-baseline="middle" class="donut-label" style="font-size: {seg.angle < 20 ? 12 : 15}px">{seg.cont}</text>
</g>
{/each}
<circle cx="90" cy="90" r="30" fill="#f8fafc" />
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
</svg>
{:else}
<svg viewBox="0 0 180 180" class="donut-svg">
<circle cx="90" cy="90" r="65" fill="#e2e8f0" />
<circle cx="90" cy="90" r="30" fill="#f8fafc" />
<svg viewBox="-25 -25 230 230" class="donut-svg">
<circle cx="90" cy="90" r="65" fill="var(--border)" />
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
</svg>
{/if}
</div>
@@ -157,11 +155,11 @@
<style>
.panel {
flex: 0 0 min(360px, 25vw);
background: #f8fafc;
border-left: 1px solid #dce8f0;
background: var(--bg-raised);
border-left: 1px solid var(--border);
display: flex;
flex-direction: row;
font-family: sans-serif;
font-family: var(--sans);
transition: flex-basis 0.25s ease;
}
@@ -180,21 +178,21 @@
.collapse-btn {
flex: 0 0 auto;
align-self: flex-start;
background: #e2e8f0;
background: var(--accent-bg);
border: none;
border-radius: 0 8px 8px 0;
padding: 14px 5px;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #1e293b;
color: var(--accent);
transition: background 0.15s ease, padding 0.15s ease;
margin-top: 24px;
position: relative;
}
.collapse-btn:hover {
background: #94a3b8;
background: var(--lavender-bg);
padding-right: 8px;
}
@@ -204,10 +202,11 @@
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
background: #1e293b;
color: #fff;
font-size: 14px;
font-weight: 600;
background: var(--text-h);
color: var(--bg-raised);
font-family: var(--sans);
font-size: 12px;
font-weight: 300;
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
@@ -221,57 +220,65 @@
}
.headline {
font-size: 16px;
font-weight: 700;
font-family: var(--heading);
font-size: var(--text-sm);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 1px;
color: #1f2937;
letter-spacing: 0.1em;
color: var(--accent);
margin: 0 0 20px 0;
}
.bar-label {
font-size: 13px;
font-weight: 600;
font-family: var(--sans);
font-size: var(--text-xs);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
letter-spacing: 0.08em;
color: var(--text-sub);
display: block;
margin-bottom: 8px;
}
.total-bar-wrap {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.total-bar-bg {
flex: 1;
height: 20px;
background: #e2e8f0;
position: relative;
height: 28px;
background: var(--accent-bg);
border-radius: 10px;
overflow: hidden;
}
.total-bar-fill {
height: 100%;
background: #3b82f6;
background: linear-gradient(90deg, var(--accent-dark), var(--lavender));
border-radius: 10px;
transition: width 0.3s ease;
min-width: 0;
}
.bar-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-h);
}
.total-bar-text {
font-size: 15px;
font-weight: 700;
color: #1f2937;
white-space: nowrap;
display: block;
text-align: center;
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
margin-top: 6px;
}
.divider {
height: 1px;
background: #e2e8f0;
background: var(--border);
margin: 16px 0;
}
@@ -279,51 +286,51 @@
display: flex;
align-items: center;
gap: 8px;
padding: 7px 0;
padding: 6px 0;
}
.dot {
width: 14px;
height: 14px;
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
flex: 1;
font-size: 14px;
color: #334155;
font-size: var(--text-sm);
font-weight: 300;
color: var(--text);
}
.value {
font-size: 14px;
font-weight: 600;
color: #1f2937;
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
}
.total {
font-weight: 350;
color: #94a3b8;
font-size: 13px;
font-weight: 400;
color: var(--text-sub);
font-size: var(--text-xs);
}
.donut-wrap {
display: flex;
justify-content: center;
margin: 24px 0;
padding: 0 10px;
margin: 8px 0 20px;
}
.donut-svg {
width: 160px;
height: 160px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
overflow: visible;
width: 180px;
height: 180px;
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15));
}
.donut-label {
fill: #1f2937;
font-weight: 600;
fill: var(--text-h);
font-family: var(--sans);
font-weight: 300;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
@@ -342,13 +349,14 @@
position: absolute;
top: calc(100% + 6px);
left: 0;
background: #1e293b;
color: #f1f5f9;
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: 0 8px 24px rgba(0, 0, 0, 0.25);
box-shadow: var(--shadow);
z-index: 20;
white-space: nowrap;
min-width: 120px;
@@ -368,9 +376,9 @@
}
.disclaimer {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
font-size: var(--text-xs);
color: var(--text-sub);
line-height: 1.5;
text-align: center;
}
</style>

View File

@@ -3,7 +3,13 @@
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
import { getSelected, toggle, setTotalCount, getHomeCountryCode } 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();
const TERRITORY_PARENT = {
'016': '840', // American Samoa -> United States
@@ -58,21 +64,24 @@
function countryColor(d, sel, homeCode) {
const id = effId(d);
if (!sel.has(id)) return UNVISITED_COLOR;
if (id === homeCode) return HOME_COLOR;
if (!sel.has(id)) return UNVISITED_COLOR;
return VISITED_COLOR;
}
function countryHoverColor(d, sel, homeCode) {
const id = effId(d);
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
if (id === homeCode) return HOME_COLOR_HOVER;
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
return VISITED_COLOR_HOVER;
}
let frameEl;
let _paths = null;
let _paths = $state(null);
let _g = null;
let _pathFn = null;
let _countries = null;
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
@@ -80,16 +89,60 @@
proj.scale(s).translate([w / 2, h * 0.70]);
}
function getHomeCode() {
const name = getUserProfile()?.homeCountry;
return name ? (nameToId[name] ?? null) : null;
}
function updateAllFills() {
if (!_paths || !_g) return;
const sel = getSelected();
const hc = getHomeCountryCode();
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(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
@@ -106,6 +159,9 @@
if (!f.id) f.id = 'XK';
});
_pathFn = path;
_countries = countries;
const sovereignIds = new Set(countries.map(f => effId(f)));
setTotalCount(sovereignIds.size);
@@ -121,33 +177,23 @@
.attr('class', 'tooltip')
.style('display', 'none');
function updateFill(sel) {
const s = getSelected();
const hc = getHomeCountryCode();
sel.attr('fill', d => countryColor(d, s, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, s, hc));
}
function attachEvents(sel) {
sel
.on('click', (event, d) => {
toggle(effId(d));
updateFill(d3.select(event.currentTarget));
onCountryClick(d.properties.name);
})
.on('mouseenter', (event, d) => {
const s = getSelected();
const hc = getHomeCountryCode();
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, getHomeCode()));
tooltip.style('display', 'block').text(d.properties.name);
})
.on('mousemove', (event) => {
const [x, y] = d3.pointer(event, frameEl);
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
tooltip.style('left', (x + 22) + 'px').style('top', (y - 28) + 'px');
})
.on('mouseleave', (event, d) => {
const s = getSelected();
const hc = getHomeCountryCode();
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
d3.select(event.currentTarget).attr('fill', countryColor(d, s, getHomeCode()));
tooltip.style('display', 'none');
});
}
@@ -169,14 +215,13 @@
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d);
const hc = getHomeCountryCode();
const c = _g.append('circle')
.attr('class', 'micro-state')
.datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 2)
.attr('fill', countryColor(d, getSelected(), hc))
.attr('fill', countryColor(d, getSelected(), getHomeCode()))
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c);
@@ -185,6 +230,7 @@
}
renderMicrostates();
placeHomeMarker();
const zoom = d3.zoom()
.scaleExtent([1, 32])
@@ -208,8 +254,9 @@
fitProjection(projection, width, height);
const countryPaths = _g.selectAll('path');
countryPaths.attr('d', path);
updateFill(countryPaths);
updateAllFills();
renderMicrostates();
placeHomeMarker();
}
});
@@ -222,7 +269,7 @@
});
</script>
<div bind:this={frameEl} class="map-frame"></div>
<div bind:this={frameEl} class="map-frame" style="cursor: url({crayonCursorUrl}) 4 28, crosshair;"></div>
<style>
.map-frame {
@@ -235,15 +282,15 @@
.map-frame :global(svg) {
display: block;
cursor: grab;
cursor: inherit;
}
.map-frame :global(svg:active) {
cursor: grabbing;
cursor: inherit;
}
.map-frame :global(svg path) {
cursor: pointer;
cursor: inherit;
}
.map-frame :global(.tooltip) {

View File

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

9
storage.rules Normal file
View File

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

View File

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