3 Commits

Author SHA1 Message Date
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
30 changed files with 515 additions and 1102 deletions

6
.env
View File

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

2
.gitignore vendored
View File

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

227
README.md
View File

@@ -1,43 +1,206 @@
# Svelte + Vite
# Map Journal
This template should help get you started developing with Svelte in Vite.
**Author:** _[Your Name]_
**Student ID:** _[Your ID]_
**Email:** _[Your Email]_
## Recommended IDE Setup
**Repository:** <https://git.prototyping.id/20256426/Map-Jurnal.git>
**Video Demo:** _[YouTube URL]_
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
---
## Need an official Svelte framework?
## Overview
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.
Map Journal is a single-page web application for documenting and visualizing travel experiences. Users log trips by country, pinning cities, dates, transport modes, and photos, and then explore their journey on an interactive world map or a timeline view.
## Technical considerations
### How It Works
**Why use this over SvelteKit?**
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** via a multi-step form:
- **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.
- 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.
---
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.
## Code Organization
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.
**Why include `.vscode/extensions.json`?**
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?**
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.
**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).
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
```
### Architecture
**Component tree** (simplified):
```
App.svelte
└── Layout.svelte
├── LoginOverlay.svelte
├── TopBar.svelte
├── CountryPicker.svelte
├── WorldMap.svelte
│ └── JourneyView.svelte
│ └── StatsPanel.svelte
└── TimelineView.svelte
├── TimelineCard.svelte
├── JournalDetail.svelte
│ ├── DeleteConfirm.svelte
│ └── EditForm.svelte
│ ├── StepNavbar.svelte
│ ├── TripBasicInfo.svelte
│ └── PhotoEditor.svelte
├── NewEntryForm.svelte
│ ├── StepNavbar.svelte
│ ├── PhotoEditor.svelte
│ └── ...
├── ShareCard.svelte
└── SharePreview.svelte
```
### Data Flow
```
Firebase Auth ──→ userStore.svelte.js ──→ Layout (auth guard)
Firebase Firestore ──→ entriesStore.svelte.js ──→ Timeline, Map (via $state/$derived)
Firebase Storage ──→ PhotoEditor.svelte (upload)
selection.svelte.js ──→ visited set (derived from entries + home country)
```
### Key Patterns
- **Svelte 5 runes** — `$state`, `$derived`, `$effect`, `$bindable`, `$props` replace the old Svelte store/reactivity model.
- **$bindable props** — `TripBasicInfo` uses `$bindable()` for two-way binding with its parent form, keeping form state in the parent while delegating UI rendering.
- **Firebase listeners** — `entriesStore` uses `onSnapshot` for real-time Firestore sync; `userStore` uses `onAuthStateChanged`.
- **D3.js** — `WorldMap` renders a GeoJSON world map with centered zoom via `d3-geo`; `JourneyView` animates SVG flight paths.
- **No framework router** — the app uses a simple `mode` state variable (`'map' | 'journal'`) in `App.svelte` to switch between views.
---
## Features
| Feature | Details |
|---------|---------|
| Google sign-in | Firebase Auth with `GoogleAuthProvider` |
| Interactive world map | D3projected map with country highlighting, zoom, and pan |
| Animated journeys | Flightpath arcs between entries, played sequentially |
| Multistep forms | 3step wizard for both new and edit modes |
| Photo upload | Firebase Storage with CORS support, error display |
| Random trip questions | 10 curated prompts, 3 randomly chosen per entry |
| Share card | Autogenerated trip summary image via `html-to-image` |
| Home country marker | 16×16 icon placed at country centroid on map |
| Tooltips | Country name shown on hover, offset 22px from cursor |
---
## Known Bugs & Limitations
- **Photo Editor** — The `.add-btn` CSS class is unused (replaced by `.add-cell`); leftover from refactoring.
- **ShareCard** — Several CSS classes (`.stat-grid`, `.stat-box`, `.cont-section`, etc.) are defined but unused in the template; they were intended for a statistics section that was not implemented.
- **Photo upload error** — If Firebase Storage upload fails, the error is displayed but the photo grid does not automatically roll back the failed entry.
- **State_referenced_locally warnings** — `EditForm.svelte` initializes `$state` variables from `$props()` at declaration; this is intentional (onetime init on edit mode) but Svelte 5's analyzer emits warnings.
- **A11y warnings** — Some form labels lack `for`/`id` associations (transport pills, triptype toggles); these are visualonly controls where the label wraps the input, which is functional but not strictly valid per WAIARIA.
---
## Dependencies
| Package | Purpose |
|---------|---------|
| `svelte` ^5.55 | UI framework (runes, snippets, `$props`) |
| `firebase` ^12 | Auth, Firestore, Storage |
| `d3` ^7 | World map projection and SVG rendering |
| `topojson-client` | Convert TopoJSON → GeoJSON for map data |
| `world-atlas` | Country boundary data |
| `html-to-image` | Generate share card PNG |
---
## 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=...
# Dev server
npm run dev
# Production build
npm run build
```
---
## Acknowledgments
- **D3.js** — [Mike Bostock](https://d3js.org/) for the visualization library.
- **world-atlas** — [Topojson world data](https://github.com/topojson/world-atlas) by Mike Bostock.
- **Firebase** — Google for the backend suite (Auth, Firestore, Storage).
- **Svelte** — [Svelte team](https://svelte.dev/) for the frontend framework.
- **html-to-image** — [tsayen](https://github.com/bubkoo/html-to-image) for DOM-to-image capture.
- **Flag emoji** — Country-to-flag mapping based on regional indicator symbols.
- **Cursor icon** — Derived from the SVG airplane asset used in the app.
---
_This project was developed as part of a software prototyping course._

View File

@@ -1,7 +0,0 @@
[
{
"origin": ["http://localhost:5173", "https://map-jurnal.web.app", "https://map-jurnal.firebaseapp.com"],
"method": ["GET", "HEAD", "PUT", "POST", "DELETE"],
"maxAgeSeconds": 3600
}
]

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

7
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"dependencies": {
"d3": "^7.9.0",
"firebase": "^12.14.0",
"flag-icons": "^7.5.0",
"html-to-image": "^1.11.13",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
@@ -2336,12 +2335,6 @@
"@firebase/util": "1.15.1"
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",

View File

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

View File

@@ -1,11 +0,0 @@
import { getAccessToken } from 'firebase-tools';
import { writeFileSync } from 'fs';
(async () => {
try {
const token = await getAccessToken();
writeFileSync('token.txt', token);
console.log('Token saved');
} catch (e) {
console.error('Failed:', e.message);
}
})();

View File

@@ -1,36 +0,0 @@
import { readFileSync } from 'fs';
const config = JSON.parse(readFileSync(
'C:\\Users\\tomas\\.config\\configstore\\firebase-tools.json', 'utf-8'
));
const token = config.tokens.access_token;
// Try to list buckets first
const listRes = await fetch('https://storage.googleapis.com/storage/v1/b?project=map-jurnal', {
headers: { Authorization: `Bearer ${token}` },
});
const buckets = await listRes.json();
console.log('Buckets:', buckets.items?.map(b => b.name) ?? buckets);
const bucket = 'map-jurnal.appspot.com';
const corsConfig = [{
origin: ['http://localhost:5173', 'https://map-jurnal.web.app', 'https://map-jurnal.firebaseapp.com'],
method: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'],
maxAgeSeconds: 3600,
}];
const res = await fetch(`https://storage.googleapis.com/storage/v1/b/${bucket}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ cors: corsConfig }),
});
if (res.ok) {
console.log('CORS configured successfully!');
} else {
const text = await res.text();
console.error('Failed:', res.status, text);
}

View File

@@ -1,32 +0,0 @@
import { readFileSync } from 'fs';
import { Storage } from '@google-cloud/storage';
const config = JSON.parse(readFileSync('C:/Users/tomas/.config/configstore/firebase-tools.json', 'utf-8'));
const storage = new Storage({
projectId: 'map-jurnal',
credentials: {
client_email: 'firebase-cli@map-jurnal.iam.gserviceaccount.com',
// We'll use token-based auth instead
},
});
// Try with direct token
const token = config.tokens.access_token;
// List all buckets to find the right name
const [buckets] = await storage.getBuckets({ project: 'map-jurnal' });
console.log('Buckets:', buckets.map(b => b.name));
if (buckets.length > 0) {
const bucket = buckets[0];
console.log('Configuring CORS for:', bucket.name);
await bucket.setCorsConfiguration([
{
origin: ['http://localhost:5173', 'https://map-jurnal.web.app', 'https://map-jurnal.firebaseapp.com'],
method: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE'],
maxAgeSeconds: 3600,
},
]);
console.log('CORS configured!');
}

View File

@@ -117,12 +117,12 @@
bottom: 24px;
right: 24px;
z-index: 10;
padding: 12px 28px;
border-radius: 24px;
padding: 10px 22px;
border-radius: 20px;
border: none;
background: #8b5cf6;
color: #fff;
font-size: 15px;
font-size: 14px;
font-weight: 500;
gap: 6px;
cursor: pointer;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -12,7 +12,7 @@ const firebaseConfig = {
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
export const app = initializeApp(firebaseConfig);
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);

View File

@@ -1,25 +0,0 @@
<script>
import { getSelected, getTotalCount } from './selection.svelte.js';
</script>
<footer class="footer">
<span>{getSelected().size} / {getTotalCount()} countries visited</span>
</footer>
<style>
.footer {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--sans);
font-size: 12px;
font-weight: 300;
color: var(--text-sub);
border-top: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
letter-spacing: 0.06em;
text-transform: uppercase;
}
</style>

View File

@@ -1,4 +1,4 @@
import { journals } from '../stores/journalStore.js';
import { journals } from '../stores/entriesStore.svelte.js';
import { nameToId } from '../shared/countries.js';
import { getUserProfile } from '../auth/userStore.svelte.js';

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
export { journals } from './entriesStore.svelte.js';
export { addEntry as addJournal } from './entriesStore.svelte.js';

View File

@@ -1,16 +1,8 @@
<script>
import { getEntries } from '../../stores/entriesStore.svelte.js';
import { addEntry, updateEntry } from '../../stores/entriesStore.svelte.js';
import { countryNames } from '../../shared/countries.js';
import { getCitiesForCountry, ALL_CITIES } from '../../shared/cities.js';
import SearchInput from '../../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
import airplaneImg from '../../../assets/airplane.png';
import trainImg from '../../../assets/train.png';
import busImg from '../../../assets/bus.png';
import carImg from '../../../assets/car.png';
import shipImg from '../../../assets/ship.png';
import walkImg from '../../../assets/walk.png';
import StepNav from './StepNavbar.svelte';
import TripBasicInfo from './TripBasicInfo.svelte';
/**
* entry = null → "new entry" mode
@@ -22,7 +14,6 @@
let isNew = !entry;
let cities = $state([...(entry?.location.cities ?? [])]);
let cityInput = $state('');
let country = $state(entry?.location.country ?? initialCountry);
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
let days = $state(String(entry?.days ?? ''));
@@ -31,7 +22,7 @@
let memo = $state(entry?.memo ?? '');
let transport = $state(entry?.transport ?? '');
let step = $state(1); // 1 | 2 | 3
let step = $state(1);
let errors = $state({
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
@@ -41,15 +32,6 @@
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
}
const transportOptions = [
{ value: 'flight', label: 'Flight', img: airplaneImg },
{ value: 'train', label: 'Train', img: trainImg },
{ value: 'bus', label: 'Bus', img: busImg },
{ value: 'car', label: 'Car', img: carImg },
{ value: 'ship', label: 'Ship', img: shipImg },
{ value: 'walk', label: 'Walk', img: walkImg },
];
const MEMO_MAX = 100;
let wordCount = $derived(memo.trim() === '' ? 0 : memo.trim().split(/\s+/).length);
let memoOverLimit = $derived(wordCount > MEMO_MAX);
@@ -65,25 +47,6 @@
}
}
let allEntries = $derived(getEntries());
let cityOptions = $derived(
country.trim()
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
);
function addCity(val) {
const trimmed = (val ?? cityInput).trim();
if (trimmed && !cities.includes(trimmed)) {
cities = [...cities, trimmed];
}
cityInput = '';
}
function removeCity(c) {
cities = cities.filter(x => x !== c);
}
function nextStep() {
if (step === 1) {
clearErrors();
@@ -133,100 +96,22 @@
console.error('Save failed:', err);
}
}
let next = $derived(step < 3 ? nextStep : save);
</script>
<div class="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save}>Save changes</button>
{/if}
</div>
</header>
<StepNav {step} totalSteps={3} onback={prevStep} onnext={next} />
<div class="scroll">
<div class="form">
{#if step === 1}
<h1 class="page-headline">
{isNew ? 'Journal your trip!' : 'Edit your trip'}
</h1>
<div class="row">
<div class="field">
<label class="label" for="edit-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="edit-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
{#each cities as c}
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
{/each}
</div>
{/if}
</div>
</div>
<div class="row">
<div class="field">
<label class="label" for="edit-date">When did you <span class="kw">arrive</span>? <span class="req">*</span></label>
<input id="edit-date" class="input" type="date" bind:value={date} required />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="edit-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="edit-tripType" value={t} bind:group={tripType} />
{t === 'solo' ? '🧑 Solo' : t === 'friends' ? '👥 With friends' : '👨‍👩‍👧‍👦 With family'}
</label>
{/each}
</div>
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
</div>
<div class="field">
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
<input type="radio" name="edit-transport" value={opt.value} bind:group={transport} />
<img src={opt.img} alt={opt.label} class="transport-img" />
{opt.label}
</label>
{/each}
</div>
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
</div>
<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>
@@ -258,70 +143,6 @@
font-family: var(--sans);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 52px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active { background: var(--accent); transform: scale(1.25); }
.step-dot.done { background: var(--accent); opacity: 0.35; }
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 15px;
font-weight: 400;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
font-family: var(--sans);
font-size: 15px;
font-weight: 400;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 10px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.scroll { flex: 1; overflow-y: auto; }
.form {
@@ -333,13 +154,6 @@
gap: 18px;
}
.page-headline {
font-size: 28px;
font-weight: 500;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0 0 4px;
}
.step-title {
font-size: 20px;
font-weight: 400;
@@ -354,7 +168,6 @@
margin: -10px 0 4px;
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.label-row {
@@ -370,9 +183,6 @@
text-transform: uppercase;
color: var(--text-h);
}
.req { color: var(--accent); font-size: 11px; }
.kw { color: var(--accent); }
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
.char-count { font-size: 11px; font-weight: 300; color: var(--text-sub); transition: color 0.15s; }
.char-count.over { color: #dc2626; }
@@ -395,35 +205,4 @@
.input:focus { border-color: var(--accent-border); }
.textarea { resize: vertical; line-height: 1.6; }
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; }
.toggle-opt {
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 14px; font-weight: 400; color: var(--text);
padding: 12px 14px; border-radius: 10px;
border: 1px solid var(--border);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
background: var(--bg-subtle);
white-space: nowrap;
}
.toggle-opt input { display: none; }
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.transport-opt { flex-direction: column; gap: 6px; padding: 16px 10px; }
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 300; color: var(--accent);
background: var(--accent-bg); border: 1px solid var(--accent-border);
border-radius: 20px; padding: 3px 10px 3px 12px;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
</style>

View File

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

View File

@@ -1,11 +1,12 @@
<script>
import { journals, addJournal } from '../../stores/journalStore.js';
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 { countryCities } from '../../shared/countryCities.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';
@@ -25,8 +26,12 @@
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
let country = $state(initialCountry);
let country = $state('');
let date = $state(new Date().toISOString().slice(0, 10));
$effect(() => {
country = initialCountry;
});
let days = $state('');
let tripType = $state('');
let transport = $state('');
@@ -65,7 +70,7 @@
let cityOptions = $derived(
country.trim()
? [...new Set([
...(countryCities[country.trim()] ?? []),
...(ALL_CITIES[country.trim()] ?? []),
...journalEntries.filter(j => (j.location?.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location?.cities ?? []),
])]
: []
@@ -128,7 +133,7 @@
.filter(Boolean)
.join('\n\n');
try {
await addJournal({
await addEntry({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
@@ -145,34 +150,12 @@
saveError = e?.message ?? 'Failed to save. Please try again.';
}
}
let next = $derived(step < 3 ? nextStep : save);
</script>
<div class="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save trip'}
</button>
{#if saveError}<span class="save-err">{saveError}</span>{/if}
{/if}
</div>
</header>
<StepNav {step} onback={prevStep} onnext={next} {saving} saveLabel="Save trip" {saveError} />
<div class="scroll">
<div class="form">
@@ -283,78 +266,6 @@
font-family: var(--sans);
}
/* topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 52px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active {
background: var(--accent);
transform: scale(1.25);
}
.step-dot.done {
background: var(--accent);
opacity: 0.35;
}
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 15px;
font-weight: 400;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
font-family: var(--sans);
font-size: 15px;
font-weight: 400;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 10px;
padding: 8px 18px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* scroll + form */
.scroll { flex: 1; overflow-y: auto; }

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">
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="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">
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
<input type="radio" name="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

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

View File

@@ -1,6 +1,5 @@
<script>
import { getEntries } from '../../stores/entriesStore.svelte.js';
import TimelineToolbar from './TimelineToolbar.svelte';
import TimelineCard from './TimelineCard.svelte';
import JournalDetail from '../detail/JournalDetail.svelte';
import EditForm from '../detail/EditForm.svelte';
@@ -78,8 +77,6 @@
<div class="two-col">
<div class="left-col">
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p>
{:else}

View File

@@ -4,7 +4,7 @@
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
import { get } from 'svelte/store';
import { journals } from '../stores/journalStore.js';
import { journals } from '../stores/entriesStore.svelte.js';
import airplaneImg from '../../assets/airplane-animation.png';
let { onclose, onprogress, mode = 'map', onmodechange } = $props();