Compare commits
29 Commits
feature/ux
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 522e41cb79 | |||
| caaae887f9 | |||
| 3ae1b25b0c | |||
| e6bc251386 | |||
| 4e1981e1f6 | |||
| 59b89fb276 | |||
| 5231b0ead7 | |||
| 6fc0172ec3 | |||
| c27200d9cd | |||
| 8fbe40f178 | |||
| 8b52437655 | |||
| 5e36f6e140 | |||
| 2f49c35128 | |||
| 3e43d09723 | |||
| 0f1e43d82d | |||
| 944b73d215 | |||
| f22b0f1b28 | |||
| 5e943df6ef | |||
| cbdf489f1b | |||
| e86ec2bbb9 | |||
| 2ecd3ca81d | |||
| 1743e7fcbe | |||
| d614ddb322 | |||
|
|
ed415a78a1 | ||
|
|
9109d6a861 | ||
|
|
5a95fccd70 | ||
| 8d36c3faca | |||
|
|
b518016a21 | ||
|
|
665472b281 |
@@ -3,7 +3,7 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Map-Jurnal",
|
||||
"cwd": "/Users/haerikim/Desktop/SP Map Journal/Map-Jurnal",
|
||||
"cwd": ".",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
|
||||
6
.env
@@ -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
|
||||
17
.firebase/hosting.ZGlzdA.cache
Normal 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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "map-jurnal"
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
@@ -23,3 +23,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.claude/
|
||||
|
||||
140
README.md
@@ -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.
|
||||

|
||||
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)
|
||||
|
||||
|
||||
@@ -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...
|
||||
|
||||
[32m[1mVITE[22m v8.0.15[39m [2mready in [0m[1m1792[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5175[22m/[39m
|
||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||
12
firebase.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
"hosting": {
|
||||
"public": "dist",
|
||||
"ignore": ["firebase.json", ".firebaserc"],
|
||||
"rewrites": [
|
||||
{ "source": "**", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
}
|
||||
11
package-lock.json
generated
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "map-journal",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "map-journal",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
BIN
public/DB-diagram.PNG
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 963 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
@@ -117,13 +117,13 @@
|
||||
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-weight: 600;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/default-1.jpeg
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
src/assets/default-2.jpeg
Normal file
|
After Width: | Height: | Size: 663 KiB |
BIN
src/assets/default-3.jpeg
Normal file
|
After Width: | Height: | Size: 566 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 290 KiB |
BIN
src/assets/logo-signin.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 112 KiB |
BIN
src/assets/profile.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { signInWithGoogle } from './userStore.svelte.js';
|
||||
import logoImg from '../../assets/logo.png';
|
||||
import logoImg from '../../assets/logo-signin.png';
|
||||
</script>
|
||||
|
||||
<div class="overlay">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -137,7 +137,7 @@
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
let selected = $state(new Set());
|
||||
let totalCountries = $state(0);
|
||||
@@ -11,6 +12,11 @@ journals.subscribe((entries) => {
|
||||
const id = nameToId[e.location?.country];
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
const profile = getUserProfile();
|
||||
if (profile?.homeCountry) {
|
||||
const homeId = nameToId[profile.homeCountry];
|
||||
if (homeId) ids.add(homeId);
|
||||
}
|
||||
selected = ids;
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { journals } from './entriesStore.svelte.js';
|
||||
export { addEntry as addJournal } from './entriesStore.svelte.js';
|
||||
@@ -1,10 +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 StepNav from './StepNavbar.svelte';
|
||||
import TripBasicInfo from './TripBasicInfo.svelte';
|
||||
|
||||
/**
|
||||
* entry = null → "new entry" mode
|
||||
@@ -13,18 +11,28 @@
|
||||
*/
|
||||
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 ?? [])]);
|
||||
let cityInput = $state('');
|
||||
// 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: ''
|
||||
});
|
||||
@@ -33,15 +41,6 @@
|
||||
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
|
||||
}
|
||||
|
||||
const transportOptions = [
|
||||
{ value: 'flight', label: '✈ Flight' },
|
||||
{ value: 'train', label: '🚂 Train' },
|
||||
{ value: 'bus', label: '🚌 Bus' },
|
||||
{ value: 'car', label: '🚗 Car' },
|
||||
{ value: 'ship', label: '🚢 Ship' },
|
||||
{ value: 'walk', label: '🚶 Walk' },
|
||||
];
|
||||
|
||||
const MEMO_MAX = 100;
|
||||
let wordCount = $derived(memo.trim() === '' ? 0 : memo.trim().split(/\s+/).length);
|
||||
let memoOverLimit = $derived(wordCount > MEMO_MAX);
|
||||
@@ -50,7 +49,6 @@
|
||||
const raw = e.currentTarget.value;
|
||||
const words = raw.trim() === '' ? [] : raw.trim().split(/\s+/);
|
||||
if (words.length > MEMO_MAX) {
|
||||
// keep first 100 words, preserve trailing space if user is mid-word
|
||||
memo = words.slice(0, MEMO_MAX).join(' ');
|
||||
e.currentTarget.value = memo;
|
||||
} else {
|
||||
@@ -58,37 +56,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest cities — when a country is selected show only cities from that country.
|
||||
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];
|
||||
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;
|
||||
}
|
||||
cityInput = '';
|
||||
step++;
|
||||
}
|
||||
|
||||
function removeCity(c) {
|
||||
cities = cities.filter(x => x !== c);
|
||||
function prevStep() {
|
||||
if (step === 1) onBack();
|
||||
else step--;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
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;
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
await addEntry({
|
||||
@@ -117,208 +105,80 @@
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let next = $derived(step < 3 ? nextStep : save);
|
||||
</script>
|
||||
|
||||
<div class="edit-layout">
|
||||
<div class="layout">
|
||||
<StepNav {step} totalSteps={3} onback={prevStep} onnext={next} />
|
||||
|
||||
<header class="edit-topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="topbar-btn" onclick={onBack}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<span class="topbar-title">{isNew ? 'New trip' : 'Edit'}</span>
|
||||
<div class="topbar-right">
|
||||
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="scroll">
|
||||
<div class="form">
|
||||
|
||||
<div class="edit-scroll">
|
||||
<form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
{#if step === 1}
|
||||
<TripBasicInfo
|
||||
bind:country bind:cities
|
||||
bind:date bind:days bind:tripType bind:transport
|
||||
bind:errors {isNew}
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
{: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">
|
||||
<label class="label" for="edit-country">Country <span class="req">*</span></label>
|
||||
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
|
||||
{#if errors.country}<span class="field-error">{errors.country}</span>{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="edit-city">Cities <span class="req">*</span></label>
|
||||
<div class="city-input-row">
|
||||
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||
<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>
|
||||
{#if errors.cities}<span class="field-error">{errors.cities}</span>{/if}
|
||||
{#if cities.length > 0}
|
||||
<div class="city-tags">
|
||||
{#each cities as c}
|
||||
<span class="city-tag">
|
||||
{c}
|
||||
<button type="button" class="city-tag-remove" onclick={() => removeCity(c)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="8" value={memo} oninput={onMemoInput}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label class="label" for="edit-date">Date <span class="req">*</span></label>
|
||||
<input id="edit-date" class="input" type="date" bind:value={date} required />
|
||||
{#if errors.date}<span class="field-error">{errors.date}</span>{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="edit-days">Days <span class="req">*</span></label>
|
||||
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
|
||||
{#if errors.days}<span class="field-error">{errors.days}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Trip type</label>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle-opt" class:active={tripType === 'solo'}>
|
||||
<input type="radio" name="tripType" value="solo" bind:group={tripType} /> Solo
|
||||
</label>
|
||||
<label class="toggle-opt" class:active={tripType === 'friends'}>
|
||||
<input type="radio" name="tripType" value="friends" bind:group={tripType} /> With friends
|
||||
</label>
|
||||
<label class="toggle-opt" class:active={tripType === 'family'}>
|
||||
<input type="radio" name="tripType" value="family" bind:group={tripType} /> With family
|
||||
</label>
|
||||
</div>
|
||||
{#if errors.tripType}<span class="field-error">{errors.tripType}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">How did you get there?</label>
|
||||
<div class="transport-grid">
|
||||
{#each transportOptions as opt}
|
||||
<label class="transport-opt" class:active={transport === opt.value}>
|
||||
<input type="radio" name="transport" value={opt.value} bind:group={transport} />
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if errors.transport}<span class="field-error">{errors.transport}</span>{/if}
|
||||
</div>
|
||||
|
||||
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||||
|
||||
<div class="field">
|
||||
<div class="label-row">
|
||||
<label class="label" for="edit-memo">How was it?</label>
|
||||
<span class="char-count" class:over={memoOverLimit}>{wordCount} / {MEMO_MAX} words</span>
|
||||
</div>
|
||||
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="4" value={memo} oninput={onMemoInput}></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field-error {
|
||||
font-size: 11px;
|
||||
color: #dc2626;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.edit-layout {
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.edit-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.topbar-right { justify-content: flex-end; }
|
||||
|
||||
.topbar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.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--save {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.topbar-btn--save:hover {
|
||||
background: var(--accent-dark);
|
||||
border-color: var(--accent-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.edit-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.scroll { flex: 1; overflow-y: auto; }
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 48px 80px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
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;
|
||||
@@ -330,23 +190,13 @@
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-sub);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 11px;
|
||||
font-weight: 300;
|
||||
color: var(--text-sub);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.char-count { font-size: 11px; font-weight: 300; color: var(--text-sub); transition: color 0.15s; }
|
||||
.char-count.over { color: #dc2626; }
|
||||
.input-over { border-color: #fca5a5; }
|
||||
|
||||
.req {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
@@ -359,101 +209,9 @@
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus { border-color: var(--accent-border); }
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggle-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
padding: 7px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.toggle-opt input { display: none; }
|
||||
.toggle-opt.active {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.transport-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.transport-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
background: var(--bg-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.transport-opt input { display: none; }
|
||||
.transport-opt.active {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.city-input-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.city-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.city-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;
|
||||
}
|
||||
.city-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.city-tag-remove:hover { opacity: 1; }
|
||||
|
||||
.textarea { resize: vertical; line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -84,9 +84,10 @@
|
||||
<div class="photo-grid">
|
||||
{#each entry.photos as photo, i}
|
||||
<div class="photo-cell" class:cell-wide={i === 0 && entry.photos.length > 1}>
|
||||
<img src={photo} alt=""
|
||||
onclick={() => lightboxSrc = photo}
|
||||
onerror={(e) => e.currentTarget.parentElement.classList.add('cell-broken')} />
|
||||
<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>
|
||||
@@ -254,21 +255,24 @@
|
||||
grid-column: 1 / -1;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.photo-cell img {
|
||||
.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 img { transform: scale(1.03); }
|
||||
.photo-cell.cell-broken {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-sub);
|
||||
font-size: 12px;
|
||||
}
|
||||
.photo-cell:hover .photo-btn img { transform: scale(1.03); }
|
||||
|
||||
|
||||
.no-photos {
|
||||
display: flex;
|
||||
|
||||
@@ -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<JNL{String(stats.tripCount).padStart(2,'0')}<<<<<<<<<<<<<<<<<<<<<<<<<<<</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>
|
||||
@@ -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,51 +150,35 @@
|
||||
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">
|
||||
|
||||
{#if step === 1}
|
||||
<!-- ── STEP 1: Details ── -->
|
||||
<h2 class="step-title">Trip details</h2>
|
||||
<!-- 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">Where did you go? <span class="req">*</span></label>
|
||||
<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 cities did you visit? <span class="req">*</span></label>
|
||||
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} onblurcommit={addCity} />
|
||||
<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">
|
||||
@@ -203,19 +192,19 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label class="label" for="nc-date">When did you travel? <span class="req">*</span></label>
|
||||
<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 long was the trip? <span class="req">*</span></label>
|
||||
<label class="label" for="nc-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
|
||||
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
|
||||
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Who did you travel with? <span class="req">*</span></label>
|
||||
<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}>
|
||||
@@ -228,13 +217,13 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">How did you get there? <span class="req">*</span></label>
|
||||
<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="transport-opt" class:active={transport === opt.value}>
|
||||
<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" />
|
||||
<span class="transport-label">{opt.label}</span>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -249,7 +238,12 @@
|
||||
|
||||
{:else}
|
||||
<!-- ── STEP 3: Questions ── -->
|
||||
<h2 class="step-title">Your memories</h2>
|
||||
<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">
|
||||
@@ -272,79 +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: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
|
||||
|
||||
.save-btn {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.save-err { font-size: 12px; color: #ef4444; margin-top: 4px; display: block; text-align: right; }
|
||||
|
||||
/* scroll + form */
|
||||
.scroll { flex: 1; overflow-y: auto; }
|
||||
|
||||
@@ -364,6 +285,14 @@
|
||||
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;
|
||||
@@ -384,27 +313,9 @@
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.req { color: var(--accent); font-size: 11px; }
|
||||
.kw { color: var(--accent); }
|
||||
|
||||
.ferr { font-size: 11px; color: #dc2626; }
|
||||
|
||||
.combo-select, .city-text {
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: var(--text-h);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
.combo-select:focus, .city-text:focus { border-color: var(--accent-border); }
|
||||
.combo-select { margin-bottom: 6px; cursor: pointer; }
|
||||
.city-text { margin-top: 0; }
|
||||
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
|
||||
|
||||
.input {
|
||||
font-family: var(--sans);
|
||||
@@ -422,33 +333,23 @@
|
||||
}
|
||||
.input:focus { border-color: var(--accent-border); }
|
||||
|
||||
.toggle-row { display: flex; gap: 8px; }
|
||||
.toggle-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.toggle-opt {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; font-weight: 300; color: var(--text);
|
||||
padding: 7px 14px; border-radius: 8px;
|
||||
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;
|
||||
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-border); background: var(--accent-bg); color: var(--accent); }
|
||||
.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 {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 8px; aspect-ratio: 1;
|
||||
border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle);
|
||||
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.transport-opt input { display: none; }
|
||||
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); }
|
||||
.transport-img { width: 60px; height: 60px; object-fit: contain; }
|
||||
.transport-label {
|
||||
font-size: 12px; font-weight: 300; color: var(--text-sub);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.transport-opt.active .transport-label { color: var(--accent); }
|
||||
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
|
||||
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
.tag {
|
||||
@@ -467,11 +368,12 @@
|
||||
.q-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
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;
|
||||
@@ -483,10 +385,10 @@
|
||||
}
|
||||
.q-input {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--text-h);
|
||||
background: var(--bg);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
|
||||
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 = '';
|
||||
@@ -61,13 +66,17 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}>
|
||||
<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>
|
||||
@@ -92,23 +101,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.add-btn:hover { background: rgba(124,58,237,0.12); }
|
||||
|
||||
.empty-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -183,4 +175,15 @@
|
||||
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>
|
||||
|
||||
100
src/lib/timeline/detail/StepNavbar.svelte
Normal 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>
|
||||
188
src/lib/timeline/detail/TripBasicInfo.svelte
Normal 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>
|
||||
@@ -1,5 +1,7 @@
|
||||
<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();
|
||||
@@ -85,11 +87,59 @@
|
||||
};
|
||||
});
|
||||
|
||||
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 {
|
||||
const dataUrl = await toPng(cardEl, { pixelRatio: 3 });
|
||||
await document.fonts.ready;
|
||||
|
||||
let fontFaceRule = '';
|
||||
try {
|
||||
const fontData = await getFontDataUrl();
|
||||
if (fontData) {
|
||||
fontFaceRule = `@font-face { font-family: 'Bricolage Grotesque'; src: url('${fontData}') format('woff2'); font-weight: 100 900; font-style: normal; }`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Inject font as a real <style> inside the card so html-to-image clones it
|
||||
let injected = null;
|
||||
if (fontFaceRule) {
|
||||
injected = document.createElement('style');
|
||||
injected.textContent = fontFaceRule;
|
||||
cardEl.prepend(injected);
|
||||
}
|
||||
|
||||
const opts = { pixelRatio: 3 };
|
||||
await toPng(cardEl, opts); // first pass loads resources
|
||||
const dataUrl = await toPng(cardEl, opts);
|
||||
|
||||
if (injected) injected.remove();
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.download = 'my-journey.png';
|
||||
a.href = dataUrl;
|
||||
@@ -135,35 +185,22 @@
|
||||
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<span class="card-brand">MAP JOURNAL</span>
|
||||
<span class="card-brand">JOURNI</span>
|
||||
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="profile-wrap">
|
||||
<img class="profile-img" src={profileImg} alt="profile" />
|
||||
</div>
|
||||
|
||||
<!-- Hero stat -->
|
||||
<div class="hero">
|
||||
<p class="hero-num">{stats.totalDays}</p>
|
||||
<p class="hero-label">days of travel</p>
|
||||
<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>
|
||||
|
||||
<!-- Stat grid -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat-box">
|
||||
<p class="stat-num">{stats.countries.length}</p>
|
||||
<p class="stat-desc">countries</p>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<p class="stat-num">{stats.cities.length}</p>
|
||||
<p class="stat-desc">cities</p>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<p class="stat-num">{stats.flightHrs}h</p>
|
||||
<p class="stat-desc">in the air</p>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<p class="stat-num">{Object.keys(stats.contDays).length}</p>
|
||||
<p class="stat-desc">continents</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fun facts -->
|
||||
<div class="facts">
|
||||
@@ -179,12 +216,6 @@
|
||||
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.cities.join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.flightHrs > 0}
|
||||
<div class="fact">
|
||||
<span class="fact-icon">✈️</span>
|
||||
<span class="fact-text">~<strong>{stats.flightHrs} hrs</strong> crossing skies</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.favCountry && stats.favCountry[1] > 1}
|
||||
<div class="fact">
|
||||
<span class="fact-icon">❤️</span>
|
||||
@@ -197,29 +228,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continent bar -->
|
||||
{#if Object.keys(stats.contDays).length > 0}
|
||||
<div class="cont-section">
|
||||
<div class="cont-bar">
|
||||
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
|
||||
<div class="cont-seg" style="flex:{days}; background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"
|
||||
title="{cont}: {days}d"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="cont-legend">
|
||||
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
|
||||
<span class="cont-item">
|
||||
<span class="cont-dot" style="background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"></span>
|
||||
{cont} {days}d
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="card-footer">
|
||||
<span>mapjournal.app</span>
|
||||
<span>journi</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -352,57 +364,41 @@
|
||||
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-num {
|
||||
font-size: 88px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -4px;
|
||||
color: #fff;
|
||||
}
|
||||
.hero-label {
|
||||
.hero-pre, .hero-post {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: #a5b4fc;
|
||||
color: rgba(255,255,255,0.6);
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stat grid */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.stat-box {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
.big-num {
|
||||
font-size: 88px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
letter-spacing: -4px;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-desc {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
color: rgba(255,255,255,0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Facts */
|
||||
@@ -432,42 +428,6 @@
|
||||
}
|
||||
.fact-text strong { color: #fff; font-weight: 400; }
|
||||
|
||||
/* Continent bar */
|
||||
.cont-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.cont-bar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cont-seg { border-radius: 3px; min-width: 4px; }
|
||||
.cont-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
.cont-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 300;
|
||||
color: rgba(255,255,255,0.5);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cont-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
|
||||
@@ -42,10 +42,6 @@
|
||||
|
||||
<div class="pc-header">
|
||||
<span class="pc-brand">MAP JOURNAL</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pc-share-icon">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="pc-hero">
|
||||
@@ -83,7 +79,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pc-cta">Share your journey →</div>
|
||||
<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>
|
||||
|
||||
@@ -143,7 +142,13 @@
|
||||
letter-spacing: 0.2em;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
.pc-share-icon { color: #a5b4fc; flex-shrink: 0; }
|
||||
.pc-share-icon-corner {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 14px;
|
||||
color: #a5b4fc;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pc-hero {
|
||||
position: relative;
|
||||
@@ -222,14 +227,4 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pc-cta {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #a5b4fc;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<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',
|
||||
});
|
||||
}
|
||||
|
||||
let mainPhoto = $derived(entry.photos[0] ?? null);
|
||||
// Pick a stable random default based on the entry id
|
||||
function defaultPhoto(id) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return defaults[hash % defaults.length];
|
||||
}
|
||||
|
||||
let mainPhoto = $derived(entry.photos[0] ?? defaultPhoto(entry.id));
|
||||
let thumbPhotos = $derived(entry.photos.slice(1, 4));
|
||||
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
|
||||
|
||||
@@ -29,10 +41,11 @@
|
||||
<div class="v-dot" aria-hidden="true"></div>
|
||||
|
||||
<div class="v-content">
|
||||
<!-- Country above card -->
|
||||
<!-- 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 -->
|
||||
@@ -40,36 +53,10 @@
|
||||
onclick={onClick}
|
||||
onkeydown={(e) => e.key === 'Enter' && onClick()}>
|
||||
|
||||
<!-- Trip badge — top-right of card, outside photo -->
|
||||
<span class="trip-badge trip-badge--{entry.tripType}">
|
||||
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
|
||||
</span>
|
||||
|
||||
<!-- Photos -->
|
||||
<div class="photo-grid" class:has-thumbs={thumbPhotos.length > 0}>
|
||||
<div class="photo-main">
|
||||
{#if mainPhoto}
|
||||
<img src={mainPhoto} alt="" loading="lazy"
|
||||
onerror={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.nextElementSibling.style.display = 'flex';
|
||||
}} />
|
||||
<div class="photo-fallback" style="display:none">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="M21 15l-5-5L5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="photo-fallback">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="M21 15l-5-5L5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<img src={mainPhoto} alt="" loading="lazy" />
|
||||
</div>
|
||||
|
||||
{#if thumbPhotos.length > 0}
|
||||
@@ -99,18 +86,17 @@
|
||||
|
||||
<!-- Info bar -->
|
||||
<div class="card-info">
|
||||
<span class="city">{entry.location.cities.join(', ')}</span>
|
||||
<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>
|
||||
<span class="dot-sep">·</span>
|
||||
{/if}
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
<span class="dot-sep">·</span>
|
||||
<span>{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
||||
<span class="trip-badge trip-badge--{entry.tripType}">
|
||||
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,6 +158,14 @@
|
||||
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 {
|
||||
@@ -191,22 +185,18 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Trip badge — absolute top-right of card ── */
|
||||
/* ── Trip badge — inline in info bar ── */
|
||||
.trip-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
font-size: 11px;
|
||||
font-weight: 300;
|
||||
padding: 3px 10px;
|
||||
font-weight: 400;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.04em;
|
||||
backdrop-filter: blur(6px);
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trip-badge--solo { background: rgba(245,158,11,0.85); color: #fff; }
|
||||
.trip-badge--friends { background: rgba(124,58,237,0.85); color: #fff; }
|
||||
.trip-badge--family { background: rgba(16,185,129,0.85); color: #fff; }
|
||||
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; border: 1px solid rgba(245,158,11,0.25); }
|
||||
.trip-badge--friends { background: rgba(124,58,237,0.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 {
|
||||
@@ -294,16 +284,12 @@
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.city {
|
||||
font-size: 13px;
|
||||
.days-label {
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,18 +77,16 @@
|
||||
|
||||
<div class="two-col">
|
||||
<div class="left-col">
|
||||
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
|
||||
|
||||
{#if sortedEntries.length === 0}
|
||||
<p class="empty">No journal entries yet.</p>
|
||||
{:else}
|
||||
<div class="sort-row">
|
||||
<span class="sort-label">Sort by</span>
|
||||
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
|
||||
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
|
||||
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
|
||||
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A → Z</option>
|
||||
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z → A</option>
|
||||
<select 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">
|
||||
|
||||
@@ -4,27 +4,16 @@
|
||||
import { feature } from 'topojson-client';
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
import { get } from 'svelte/store';
|
||||
import { journals } from '../stores/journalStore.js';
|
||||
import airplaneImg from '../../assets/airplane.png';
|
||||
import trainImg from '../../assets/train.png';
|
||||
import busImg from '../../assets/bus.png';
|
||||
import carImg from '../../assets/car.png';
|
||||
import shipImg from '../../assets/ship.png';
|
||||
import walkImg from '../../assets/walk.png';
|
||||
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 = '203';
|
||||
const HOME_CODE = $derived(nameToId[getUserProfile()?.homeCountry] ?? null);
|
||||
|
||||
const TRANSPORT_IMG = {
|
||||
flight: airplaneImg,
|
||||
train: trainImg,
|
||||
bus: busImg,
|
||||
car: carImg,
|
||||
ship: shipImg,
|
||||
walk: walkImg,
|
||||
};
|
||||
const PLANE_SIZE = 28;
|
||||
const PLANE_SIZE = 26;
|
||||
|
||||
const HOME_COLOR = '#8b5cf6';
|
||||
const VISITED_COLOR = '#22c55e';
|
||||
@@ -229,7 +218,7 @@
|
||||
|
||||
async function animateTrip(destCode, destFeature, transport = 'flight') {
|
||||
if (!homeFeature || !destFeature) return;
|
||||
const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg;
|
||||
const iconSrc = airplaneImg;
|
||||
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||
const destCentroid = d3.geoCentroid(destFeature);
|
||||
if (mode === 'map') {
|
||||
@@ -304,23 +293,14 @@
|
||||
renderMap();
|
||||
|
||||
const nameToId = Object.fromEntries(Object.entries(featuresById).filter(([,f]) => f.properties?.name).map(([id, f]) => [f.properties.name, id]));
|
||||
const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
|
||||
const trips = entries.length > 0
|
||||
? entries.map(e => ({
|
||||
countryName: e.location.country,
|
||||
countryCode: nameToId[e.location.country] ?? null,
|
||||
city: e.location.cities?.[0] ?? e.location.country,
|
||||
transport: e.transport ?? 'flight',
|
||||
date: e.date,
|
||||
})).filter(t => t.countryCode)
|
||||
: [
|
||||
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' },
|
||||
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' },
|
||||
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' },
|
||||
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' },
|
||||
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' },
|
||||
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' },
|
||||
];
|
||||
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;
|
||||
@@ -335,6 +315,7 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -391,11 +372,15 @@
|
||||
{#if isFinished}Journey complete!{:else if currentDateLabel}{currentDateLabel}{/if}
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<button class="control-btn" onclick={replay}>⟳ Replay</button>
|
||||
<button class="control-btn" onclick={() => switchMode(mode === 'map' ? 'globe' : 'map')}>
|
||||
{mode === 'map' ? '🌍 Globe view' : '🗺 Map view'}
|
||||
<button 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>
|
||||
<button class="control-btn" onclick={close}>✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
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';
|
||||
|
||||
@@ -63,15 +64,15 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -81,15 +82,38 @@
|
||||
let _pathFn = null;
|
||||
let _countries = null;
|
||||
|
||||
function updateHomeMarker(homeCountryName) {
|
||||
|
||||
function fitProjection(proj, w, h) {
|
||||
proj.fitSize([w, h], { type: 'Sphere' });
|
||||
const s = proj.scale() * 1.5;
|
||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||
}
|
||||
|
||||
function getHomeCode() {
|
||||
const name = getUserProfile()?.homeCountry;
|
||||
return name ? (nameToId[name] ?? null) : null;
|
||||
}
|
||||
|
||||
function updateAllFills() {
|
||||
const sel = getSelected();
|
||||
const hc = getHomeCode();
|
||||
if (!_paths || !_g) return;
|
||||
_paths.attr('fill', d => countryColor(d, sel, hc));
|
||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
||||
}
|
||||
|
||||
$effect(updateAllFills);
|
||||
|
||||
function placeHomeMarker() {
|
||||
if (!_g || !_pathFn || !_countries) return;
|
||||
_g.selectAll('.home-marker').remove();
|
||||
if (!homeCountryName) return;
|
||||
const found = _countries.find(f => f.properties.name === homeCountryName);
|
||||
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 = 24;
|
||||
const SIZE = 14;
|
||||
_g.append('image')
|
||||
.attr('class', 'home-marker')
|
||||
.attr('href', homeIconUrl)
|
||||
@@ -100,25 +124,7 @@
|
||||
.style('pointer-events', 'none');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const homeCountry = getUserProfile()?.homeCountry ?? null;
|
||||
updateHomeMarker(homeCountry);
|
||||
});
|
||||
|
||||
function fitProjection(proj, w, h) {
|
||||
proj.fitSize([w, h], { type: 'Sphere' });
|
||||
const s = proj.scale() * 1.5;
|
||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||
}
|
||||
|
||||
function updateAllFills() {
|
||||
const sel = getSelected();
|
||||
if (!_paths || !_g) return;
|
||||
_paths.attr('fill', d => countryColor(d, sel, null));
|
||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, null));
|
||||
}
|
||||
|
||||
$effect(updateAllFills);
|
||||
$effect(placeHomeMarker);
|
||||
|
||||
$effect(() => {
|
||||
const flashSet = getFlashing();
|
||||
@@ -178,16 +184,16 @@
|
||||
})
|
||||
.on('mouseenter', (event, d) => {
|
||||
const s = getSelected();
|
||||
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, null));
|
||||
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();
|
||||
d3.select(event.currentTarget).attr('fill', countryColor(d, s, null));
|
||||
d3.select(event.currentTarget).attr('fill', countryColor(d, s, getHomeCode()));
|
||||
tooltip.style('display', 'none');
|
||||
});
|
||||
}
|
||||
@@ -215,7 +221,7 @@
|
||||
.attr('cx', cx)
|
||||
.attr('cy', cy)
|
||||
.attr('r', 2)
|
||||
.attr('fill', countryColor(d, getSelected(), null))
|
||||
.attr('fill', countryColor(d, getSelected(), getHomeCode()))
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-width', 0.5);
|
||||
attachEvents(c);
|
||||
@@ -224,7 +230,7 @@
|
||||
}
|
||||
|
||||
renderMicrostates();
|
||||
updateHomeMarker(getUserProfile()?.homeCountry ?? null);
|
||||
placeHomeMarker();
|
||||
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([1, 32])
|
||||
@@ -248,8 +254,9 @@
|
||||
fitProjection(projection, width, height);
|
||||
const countryPaths = _g.selectAll('path');
|
||||
countryPaths.attr('d', path);
|
||||
updateFill(countryPaths);
|
||||
updateAllFills();
|
||||
renderMicrostates();
|
||||
placeHomeMarker();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
9
storage.rules
Normal file
@@ -0,0 +1,9 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{allPaths=**} {
|
||||
allow read: if true;
|
||||
allow write: if request.auth != null;
|
||||
}
|
||||
}
|
||||
}
|
||||