Compare commits
28 Commits
feature/wo
...
cbdf489f1b
| Author | SHA1 | Date | |
|---|---|---|---|
| cbdf489f1b | |||
| e86ec2bbb9 | |||
| 2ecd3ca81d | |||
| 1743e7fcbe | |||
| d614ddb322 | |||
|
|
ed415a78a1 | ||
|
|
9109d6a861 | ||
|
|
5a95fccd70 | ||
| 8d36c3faca | |||
|
|
b518016a21 | ||
|
|
665472b281 | ||
|
|
2226a483c5 | ||
|
|
93636b6968 | ||
|
|
5718bca963 | ||
|
|
6f41f6e53e | ||
|
|
d157055ab7 | ||
|
|
76d7e815c3 | ||
|
|
c7cf053105 | ||
|
|
a7079c1f18 | ||
|
|
cf9717149f | ||
|
|
ec4eea0977 | ||
|
|
92fae28383 | ||
|
|
b3c5fbe3dd | ||
|
|
8e9b40cc69 | ||
|
|
d389b496b4 | ||
|
|
bf2700efb7 | ||
| 0a823948df | |||
|
|
d2fb40f692 |
@@ -3,7 +3,7 @@
|
|||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Map-Jurnal",
|
"name": "Map-Jurnal",
|
||||||
"cwd": "/Users/haerikim/Desktop/SP Map Journal/Map-Jurnal",
|
"cwd": ".",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 5173,
|
"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
|
|
||||||
5
.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "map-jurnal"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
@@ -23,3 +23,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
|||||||
227
README.md
@@ -1,43 +1,206 @@
|
|||||||
# Svelte + Vite
|
# Map Journal
|
||||||
|
|
||||||
This template should help get you started developing with Svelte in Vite.
|
**Author:** _[Your Name]_
|
||||||
|
**Student ID:** _[Your ID]_
|
||||||
|
**Email:** _[Your Email]_
|
||||||
|
|
||||||
## Recommended IDE Setup
|
**Repository:** <https://git.prototyping.id/20256426/Map-Jurnal.git>
|
||||||
|
**Video Demo:** _[YouTube URL]_
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
---
|
||||||
|
|
||||||
## Need an official Svelte framework?
|
## Overview
|
||||||
|
|
||||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
Map Journal is a single-page web application for documenting and visualizing travel experiences. Users log trips by country, pinning cities, dates, transport modes, and photos, and then explore their journey on an interactive world map or a timeline view.
|
||||||
|
|
||||||
## Technical considerations
|
### How It Works
|
||||||
|
|
||||||
**Why use this over SvelteKit?**
|
1. **Sign in** with a Google account.
|
||||||
|
2. **Select your home country** — it is automatically marked as visited on the map.
|
||||||
|
3. **Add journal entries** via a multi-step form:
|
||||||
|
- **Step 1** — Choose country, cities, arrival date, days stayed, trip type (solo/friends/family), and transport (flight/train/bus/car/ship/walk).
|
||||||
|
- **Step 2** — Upload photos (stored in Firebase Storage).
|
||||||
|
- **Step 3** — Answer three random reflective questions about the trip (e.g., *"What was the most unexpected thing that happened?"*).
|
||||||
|
4. **Edit entries** through the same form, pre-filled with existing data.
|
||||||
|
5. **View your journey** on a D3-powered world map where visited countries are highlighted and animated flight paths connect entries in chronological order.
|
||||||
|
6. **Browse a timeline** sorted by date, country, or recency.
|
||||||
|
7. **Share** a generated summary card to show off your stats.
|
||||||
|
|
||||||
- It brings its own routing solution which might not be preferable for some users.
|
---
|
||||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
|
||||||
|
|
||||||
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
## Code Organization
|
||||||
|
|
||||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
|
||||||
|
|
||||||
**Why include `.vscode/extensions.json`?**
|
|
||||||
|
|
||||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
|
||||||
|
|
||||||
**Why enable `checkJs` in the JS template?**
|
|
||||||
|
|
||||||
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
|
||||||
|
|
||||||
**Why is HMR not preserving my local component state?**
|
|
||||||
|
|
||||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
|
||||||
|
|
||||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// store.js
|
|
||||||
// An extremely simple external store
|
|
||||||
import { writable } from 'svelte/store'
|
|
||||||
export default writable(0)
|
|
||||||
```
|
```
|
||||||
|
src/
|
||||||
|
├── App.svelte Root component — mode switching & replay button
|
||||||
|
├── main.js Vite entry point
|
||||||
|
├── app.css Global CSS variables and resets
|
||||||
|
├── assets/ 14 static images (transport icons, profile, defaults)
|
||||||
|
│
|
||||||
|
└── lib/
|
||||||
|
├── firebase.js Firebase init (auth, Firestore, Storage)
|
||||||
|
│
|
||||||
|
├── auth/
|
||||||
|
│ ├── LoginOverlay.svelte Google sign-in dialog
|
||||||
|
│ ├── CountryPicker.svelte Home-country selection step
|
||||||
|
│ └── userStore.svelte.js Auth state & user profile store
|
||||||
|
│
|
||||||
|
├── layout/
|
||||||
|
│ ├── Layout.svelte App shell (auth guard + sidebar)
|
||||||
|
│ ├── TopBar.svelte Segmented nav (Map / Journal)
|
||||||
|
│ └── selection.svelte.js Reactive set of visited countries
|
||||||
|
│
|
||||||
|
├── stores/
|
||||||
|
│ └── entriesStore.svelte.js Firestore CRUD & reactive list
|
||||||
|
│
|
||||||
|
├── shared/
|
||||||
|
│ ├── cities.js Country→cities map
|
||||||
|
│ ├── countries.js Country names, IDs, flag emoji helpers
|
||||||
|
│ ├── SearchInput.svelte Autocomplete text input
|
||||||
|
│ └── types.js JSDoc type definitions
|
||||||
|
│
|
||||||
|
├── world-map/
|
||||||
|
│ ├── WorldMap.svelte D3 globe — visited countries, home marker, tooltips
|
||||||
|
│ ├── JourneyView.svelte Animated flight-path overlay + stats
|
||||||
|
│ ├── StatsPanel.svelte Trip statistics panel
|
||||||
|
│ └── continents.js Continent classification data
|
||||||
|
│
|
||||||
|
└── timeline/
|
||||||
|
├── detail/
|
||||||
|
│ ├── NewEntryForm.svelte Multi-step entry creation (3 steps)
|
||||||
|
│ ├── EditForm.svelte Multi-step entry editing (3 steps)
|
||||||
|
│ ├── StepNavbar.svelte Shared step-navigation top bar
|
||||||
|
│ ├── TripBasicInfo.svelte Step 1 form (country, cities, dates, transport)
|
||||||
|
│ ├── PhotoEditor.svelte Step 2 — upload & manage photos
|
||||||
|
│ ├── JournalDetail.svelte Full entry view with lightbox
|
||||||
|
│ └── DeleteConfirm.svelte Delete confirmation dialog
|
||||||
|
└── view/
|
||||||
|
├── TimelineView.svelte Sorted entry list with year groups
|
||||||
|
├── TimelineCard.svelte Entry card thumbnail
|
||||||
|
├── ShareCard.svelte Generated share image
|
||||||
|
└── SharePreview.svelte Share card preview modal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**Component tree** (simplified):
|
||||||
|
|
||||||
|
```
|
||||||
|
App.svelte
|
||||||
|
└── Layout.svelte
|
||||||
|
├── LoginOverlay.svelte
|
||||||
|
├── TopBar.svelte
|
||||||
|
├── CountryPicker.svelte
|
||||||
|
├── WorldMap.svelte
|
||||||
|
│ └── JourneyView.svelte
|
||||||
|
│ └── StatsPanel.svelte
|
||||||
|
└── TimelineView.svelte
|
||||||
|
├── TimelineCard.svelte
|
||||||
|
├── JournalDetail.svelte
|
||||||
|
│ ├── DeleteConfirm.svelte
|
||||||
|
│ └── EditForm.svelte
|
||||||
|
│ ├── StepNavbar.svelte
|
||||||
|
│ ├── TripBasicInfo.svelte
|
||||||
|
│ └── PhotoEditor.svelte
|
||||||
|
├── NewEntryForm.svelte
|
||||||
|
│ ├── StepNavbar.svelte
|
||||||
|
│ ├── PhotoEditor.svelte
|
||||||
|
│ └── ...
|
||||||
|
├── ShareCard.svelte
|
||||||
|
└── SharePreview.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Firebase Auth ──→ userStore.svelte.js ──→ Layout (auth guard)
|
||||||
|
Firebase Firestore ──→ entriesStore.svelte.js ──→ Timeline, Map (via $state/$derived)
|
||||||
|
Firebase Storage ──→ PhotoEditor.svelte (upload)
|
||||||
|
selection.svelte.js ──→ visited set (derived from entries + home country)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
- **Svelte 5 runes** — `$state`, `$derived`, `$effect`, `$bindable`, `$props` replace the old Svelte store/reactivity model.
|
||||||
|
- **$bindable props** — `TripBasicInfo` uses `$bindable()` for two-way binding with its parent form, keeping form state in the parent while delegating UI rendering.
|
||||||
|
- **Firebase listeners** — `entriesStore` uses `onSnapshot` for real-time Firestore sync; `userStore` uses `onAuthStateChanged`.
|
||||||
|
- **D3.js** — `WorldMap` renders a GeoJSON world map with centered zoom via `d3-geo`; `JourneyView` animates SVG flight paths.
|
||||||
|
- **No framework router** — the app uses a simple `mode` state variable (`'map' | 'journal'`) in `App.svelte` to switch between views.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Details |
|
||||||
|
|---------|---------|
|
||||||
|
| Google sign-in | Firebase Auth with `GoogleAuthProvider` |
|
||||||
|
| Interactive world map | D3‑projected map with country highlighting, zoom, and pan |
|
||||||
|
| Animated journeys | Flight‑path arcs between entries, played sequentially |
|
||||||
|
| Multi‑step forms | 3‑step wizard for both new and edit modes |
|
||||||
|
| Photo upload | Firebase Storage with CORS support, error display |
|
||||||
|
| Random trip questions | 10 curated prompts, 3 randomly chosen per entry |
|
||||||
|
| Share card | Auto‑generated trip summary image via `html-to-image` |
|
||||||
|
| Home country marker | 16×16 icon placed at country centroid on map |
|
||||||
|
| Tooltips | Country name shown on hover, offset 22px from cursor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Bugs & Limitations
|
||||||
|
|
||||||
|
- **Photo Editor** — The `.add-btn` CSS class is unused (replaced by `.add-cell`); leftover from refactoring.
|
||||||
|
- **ShareCard** — Several CSS classes (`.stat-grid`, `.stat-box`, `.cont-section`, etc.) are defined but unused in the template; they were intended for a statistics section that was not implemented.
|
||||||
|
- **Photo upload error** — If Firebase Storage upload fails, the error is displayed but the photo grid does not automatically roll back the failed entry.
|
||||||
|
- **State_referenced_locally warnings** — `EditForm.svelte` initializes `$state` variables from `$props()` at declaration; this is intentional (one‑time init on edit mode) but Svelte 5's analyzer emits warnings.
|
||||||
|
- **A11y warnings** — Some form labels lack `for`/`id` associations (transport pills, trip‑type toggles); these are visual‑only controls where the label wraps the input, which is functional but not strictly valid per WAI‑ARIA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `svelte` ^5.55 | UI framework (runes, snippets, `$props`) |
|
||||||
|
| `firebase` ^12 | Auth, Firestore, Storage |
|
||||||
|
| `d3` ^7 | World map projection and SVG rendering |
|
||||||
|
| `topojson-client` | Convert TopoJSON → GeoJSON for map data |
|
||||||
|
| `world-atlas` | Country boundary data |
|
||||||
|
| `html-to-image` | Generate share card PNG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Environment — create .env with your Firebase config:
|
||||||
|
# VITE_FIREBASE_API_KEY=...
|
||||||
|
# VITE_FIREBASE_AUTH_DOMAIN=...
|
||||||
|
# VITE_FIREBASE_PROJECT_ID=...
|
||||||
|
# VITE_FIREBASE_STORAGE_BUCKET=...
|
||||||
|
# VITE_FIREBASE_MESSAGING_SENDER_ID=...
|
||||||
|
# VITE_FIREBASE_APP_ID=...
|
||||||
|
|
||||||
|
# Dev server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- **D3.js** — [Mike Bostock](https://d3js.org/) for the visualization library.
|
||||||
|
- **world-atlas** — [Topojson world data](https://github.com/topojson/world-atlas) by Mike Bostock.
|
||||||
|
- **Firebase** — Google for the backend suite (Auth, Firestore, Storage).
|
||||||
|
- **Svelte** — [Svelte team](https://svelte.dev/) for the frontend framework.
|
||||||
|
- **html-to-image** — [tsayen](https://github.com/bubkoo/html-to-image) for DOM-to-image capture.
|
||||||
|
- **Flag emoji** — Country-to-flag mapping based on regional indicator symbols.
|
||||||
|
- **Cursor icon** — Derived from the SVG airplane asset used in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_This project was developed as part of a software prototyping course._
|
||||||
|
|||||||
@@ -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
|
|
||||||
5
firebase.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"storage": {
|
||||||
|
"rules": "storage.rules"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
package-lock.json
generated
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"flag-icons": "^7.5.0",
|
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"world-atlas": "^2.0.2"
|
"world-atlas": "^2.0.2"
|
||||||
@@ -2336,12 +2335,6 @@
|
|||||||
"@firebase/util": "1.15.1"
|
"@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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"flag-icons": "^7.5.0",
|
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"world-atlas": "^2.0.2"
|
"world-atlas": "^2.0.2"
|
||||||
|
|||||||
BIN
public/logo.png
|
Before Width: | Height: | Size: 963 KiB After Width: | Height: | Size: 102 KiB |
@@ -6,7 +6,7 @@
|
|||||||
import WorldMap from './lib/world-map/WorldMap.svelte';
|
import WorldMap from './lib/world-map/WorldMap.svelte';
|
||||||
import JourneyView from './lib/world-map/JourneyView.svelte';
|
import JourneyView from './lib/world-map/JourneyView.svelte';
|
||||||
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
||||||
import TimelineView from './lib/timeline/TimelineView.svelte';
|
import TimelineView from './lib/timeline/view/TimelineView.svelte';
|
||||||
|
|
||||||
let screen = $state('worldmap');
|
let screen = $state('worldmap');
|
||||||
let journeyActive = $state(false);
|
let journeyActive = $state(false);
|
||||||
@@ -63,13 +63,14 @@
|
|||||||
<button class="journey-play-btn" onclick={startJourney}>▶ Replay My Trips</button>
|
<button class="journey-play-btn" onclick={startJourney}>▶ Replay My Trips</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<StatsPanel />
|
{#if !journeyActive}<StatsPanel />{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<TimelineView
|
<TimelineView
|
||||||
onDetailChange={(v) => (inDetail = v)}
|
onDetailChange={(v) => (inDetail = v)}
|
||||||
{pendingCountry}
|
{pendingCountry}
|
||||||
onNewEntryClear={() => (pendingCountry = '')}
|
onNewEntryClear={() => (pendingCountry = '')}
|
||||||
|
onGoToMap={() => { screen = 'worldmap'; }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -116,13 +117,13 @@
|
|||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
right: 24px;
|
right: 24px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 12px 28px;
|
padding: 10px 22px;
|
||||||
border-radius: 24px;
|
border-radius: 20px;
|
||||||
border: none;
|
border: none;
|
||||||
background: #8b5cf6;
|
background: #8b5cf6;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -140,4 +141,5 @@
|
|||||||
.journey-play-btn:active {
|
.journey-play-btn:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
|
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 |
BIN
src/assets/home.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
src/assets/logo-cursor.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/logo-signin.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
src/assets/profile.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
@@ -1,93 +1,72 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getUser, getUserProfile, setHomeCountry } from './userStore.svelte.js';
|
import { getUser, getUserProfile, setHomeCountry } from './userStore.svelte.js';
|
||||||
import worldData from 'world-atlas/countries-50m.json';
|
import { countryNames } from '../shared/countries.js';
|
||||||
|
import homeImg from '../../assets/home.png';
|
||||||
|
|
||||||
let user = $derived(getUser());
|
let user = $derived(getUser());
|
||||||
let profile = $derived(getUserProfile());
|
let profile = $derived(getUserProfile());
|
||||||
|
|
||||||
const countries = $derived.by(() => {
|
|
||||||
if (!worldData?.objects?.countries?.geometries) return [];
|
|
||||||
return worldData.objects.countries.geometries
|
|
||||||
.map(g => ({ name: g.properties?.name, code: g.id }))
|
|
||||||
.filter(c => c.name && c.code)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
});
|
|
||||||
|
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let selectedCountry = $state(null);
|
let selectedCountry = $state('');
|
||||||
|
|
||||||
let filtered = $derived(
|
|
||||||
search
|
|
||||||
? countries.filter(c => c.name.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: countries
|
|
||||||
);
|
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
|
|
||||||
function select(c) {
|
let filtered = $derived(
|
||||||
selectedCountry = c;
|
search.trim()
|
||||||
search = c.name;
|
? countryNames.filter(c => c.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: countryNames
|
||||||
|
);
|
||||||
|
|
||||||
|
function select(name) {
|
||||||
|
selectedCountry = name;
|
||||||
|
search = name;
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (selectedCountry) {
|
if (selectedCountry) {
|
||||||
setHomeCountry(selectedCountry.name, selectedCountry.code);
|
setHomeCountry(selectedCountry, selectedCountry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (e.key === 'Enter' && selectedCountry) {
|
if (e.key === 'Enter' && selectedCountry) handleSubmit();
|
||||||
handleSubmit();
|
if (e.key === 'Escape') open = false;
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1 class="heading">Welcome, {profile?.displayName || 'Traveler'}!</h1>
|
<img src={homeImg} alt="home" class="home-img" />
|
||||||
<p class="subtitle">Select your home country to get started</p>
|
<h1 class="title">Welcome, {profile?.displayName?.split(' ')[0] || 'Traveler'}!</h1>
|
||||||
|
<p class="subtitle">Where do you call home?</p>
|
||||||
|
|
||||||
<div class="dropdown" class:open>
|
<div class="dropdown">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search for a country..."
|
placeholder="Search country..."
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
onfocus={() => { open = true; }}
|
onfocus={() => { open = true; }}
|
||||||
oninput={() => { open = true; selectedCountry = null; }}
|
oninput={() => { open = true; selectedCountry = ''; }}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
class="search-input"
|
class="search-input"
|
||||||
/>
|
/>
|
||||||
{#if open}
|
{#if open && filtered.length > 0}
|
||||||
<ul class="list" role="listbox">
|
<ul class="list" role="listbox">
|
||||||
{#each filtered as country}
|
{#each filtered as name}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selectedCountry?.name === country.name}
|
aria-selected={selectedCountry === name}
|
||||||
class:selected={selectedCountry?.name === country.name}
|
class:selected={selectedCountry === name}
|
||||||
onclick={() => select(country)}
|
onmousedown={() => select(name)}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') select(country); }}
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>{name}</li>
|
||||||
{country.name}
|
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
{#if filtered.length === 0}
|
|
||||||
<li class="no-results">No countries found</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button class="continue-btn" disabled={!selectedCountry} onclick={handleSubmit}>
|
||||||
class="continue-btn"
|
Set home country
|
||||||
disabled={!selectedCountry}
|
|
||||||
onclick={handleSubmit}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,113 +75,116 @@
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(15, 23, 42, 0.85);
|
background: var(--bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
backdrop-filter: blur(4px);
|
padding-bottom: 20vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #1e2937;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 40px 36px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
max-width: 360px;
|
||||||
max-width: 420px;
|
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.home-img {
|
||||||
font: 700 24px/1.3 sans-serif;
|
width: 200px;
|
||||||
color: #f1f5f9;
|
height: 200px;
|
||||||
margin-bottom: 6px;
|
object-fit: contain;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font: 400 15px/1.4 sans-serif;
|
font-family: var(--sans);
|
||||||
color: #94a3b8;
|
font-size: 14px;
|
||||||
margin-bottom: 28px;
|
font-weight: 300;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 24px;
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 10px 14px;
|
||||||
border: 1px solid #475569;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #0f172a;
|
background: var(--bg-subtle);
|
||||||
color: #f1f5f9;
|
color: var(--text-h);
|
||||||
font: 400 15px/1.4 sans-serif;
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.15s;
|
||||||
}
|
box-sizing: border-box;
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input::placeholder {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
}
|
||||||
|
.search-input:focus { border-color: var(--accent-border); }
|
||||||
|
.search-input::placeholder { color: var(--text-sub); }
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-height: 240px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0f172a;
|
background: var(--bg);
|
||||||
border: 1px solid #475569;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li {
|
.list li {
|
||||||
padding: 10px 16px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #cbd5e1;
|
color: var(--text);
|
||||||
font: 400 14px/1.4 sans-serif;
|
font-family: var(--sans);
|
||||||
transition: background 0.15s;
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li:hover,
|
.list li:hover, .list li.selected {
|
||||||
.list li.selected {
|
background: var(--accent-bg);
|
||||||
background: #1e3a5f;
|
color: var(--accent);
|
||||||
color: #f1f5f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
|
||||||
color: #64748b;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.continue-btn {
|
.continue-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 24px;
|
padding: 11px 24px;
|
||||||
border: none;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #3b82f6;
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font: 600 16px/1.4 sans-serif;
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, opacity 0.2s;
|
transition: background 0.15s, opacity 0.15s;
|
||||||
}
|
|
||||||
|
|
||||||
.continue-btn:hover:not(:disabled) {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.continue-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
.continue-btn:hover:not(:disabled) { background: var(--accent-dark); }
|
||||||
|
.continue-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { signInWithGoogle } from './userStore.svelte.js';
|
import { signInWithGoogle } from './userStore.svelte.js';
|
||||||
|
import logoImg from '../../assets/logo-signin.png';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="/logo.png" alt="Map Journal" class="logo" />
|
<img src={logoImg} alt="Journi" class="logo" />
|
||||||
<h1 class="title">Map Journal</h1>
|
<h1 class="title">Journi</h1>
|
||||||
<p class="subtitle">Sign in to start your journey</p>
|
<p class="subtitle">Collect Colors Along the Way</p>
|
||||||
<button class="google-btn" onclick={signInWithGoogle}>
|
<button class="google-btn" onclick={signInWithGoogle}>
|
||||||
<svg class="google-icon" viewBox="0 0 48 48">
|
<svg class="google-icon" viewBox="0 0 48 48">
|
||||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||||
@@ -24,64 +25,90 @@
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(15, 23, 42, 0.85);
|
background: var(--bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding-bottom: 20vh;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #1e2937;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 48px 40px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
max-width: 360px;
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
width: 80px;
|
width: 216px;
|
||||||
height: 80px;
|
height: 216px;
|
||||||
border-radius: 12px;
|
object-fit: contain;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
animation: jitter 1.4s steps(1, end) 1 forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jitter {
|
||||||
|
0% { transform: scale(0.7) rotate(0deg); opacity: 0.5; }
|
||||||
|
8% { transform: scale(0.85) rotate(-16deg); opacity: 1; }
|
||||||
|
16% { transform: scale(1.0) rotate(16deg); }
|
||||||
|
24% { transform: scale(1.06) rotate(-16deg); }
|
||||||
|
32% { transform: scale(1.12) rotate(16deg); }
|
||||||
|
40% { transform: scale(1.16) rotate(-16deg); }
|
||||||
|
48% { transform: scale(1.2) rotate(16deg); }
|
||||||
|
56% { transform: scale(1.2) rotate(-16deg); }
|
||||||
|
64% { transform: scale(1.2) rotate(16deg); }
|
||||||
|
72% { transform: scale(1.2) rotate(-10deg); }
|
||||||
|
80% { transform: scale(1.2) rotate(10deg); }
|
||||||
|
88% { transform: scale(1.2) rotate(-4deg); }
|
||||||
|
94% { transform: scale(1.2) rotate(4deg); }
|
||||||
|
100% { transform: scale(1.2) rotate(0deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font: 700 28px/1.2 sans-serif;
|
font-family: var(--heading);
|
||||||
color: #f1f5f9;
|
font-size: 28px;
|
||||||
margin-bottom: 8px;
|
font-weight: 600;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font: 400 15px/1.4 sans-serif;
|
font-family: var(--sans);
|
||||||
color: #94a3b8;
|
font-size: 14px;
|
||||||
margin-bottom: 32px;
|
font-weight: 300;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-btn {
|
.google-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
padding: 12px 28px;
|
padding: 10px 24px;
|
||||||
border: 1px solid rgba(255,255,255,0.15);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #334155;
|
background: var(--bg-subtle);
|
||||||
color: #f1f5f9;
|
color: var(--text-h);
|
||||||
font: 500 16px/1.4 sans-serif;
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-btn:hover {
|
.google-btn:hover {
|
||||||
background: #475569;
|
background: var(--bg);
|
||||||
|
border-color: var(--accent-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-icon {
|
.google-icon {
|
||||||
width: 22px;
|
width: 20px;
|
||||||
height: 22px;
|
height: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { auth, db, googleProvider } from '../firebase.js';
|
import { auth, db, googleProvider } from '../firebase.js';
|
||||||
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
|
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
|
||||||
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
|
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
|
||||||
import { initSelectionListener } from '../layout/selection.svelte.js';
|
|
||||||
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
|
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
|
||||||
|
|
||||||
let _initialized = false;
|
let _initialized = false;
|
||||||
@@ -48,7 +47,6 @@ export function initAuth() {
|
|||||||
onAuthStateChanged(auth, async (fbUser) => {
|
onAuthStateChanged(auth, async (fbUser) => {
|
||||||
if (fbUser) {
|
if (fbUser) {
|
||||||
user = fbUser;
|
user = fbUser;
|
||||||
initSelectionListener(fbUser.uid);
|
|
||||||
initEntriesListener(fbUser.uid);
|
initEntriesListener(fbUser.uid);
|
||||||
const docRef = doc(db, 'users', fbUser.uid);
|
const docRef = doc(db, 'users', fbUser.uid);
|
||||||
const docSnap = await getDoc(docRef);
|
const docSnap = await getDoc(docRef);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const firebaseConfig = {
|
|||||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const app = initializeApp(firebaseConfig);
|
const app = initializeApp(firebaseConfig);
|
||||||
export const auth = getAuth(app);
|
export const auth = getAuth(app);
|
||||||
export const db = getFirestore(app);
|
export const db = getFirestore(app);
|
||||||
export const storage = getStorage(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>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
|
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
|
||||||
|
|
||||||
let { screen, onNavigate } = $props();
|
let { screen, onNavigate } = $props();
|
||||||
|
|
||||||
let user = $derived(getUser());
|
let user = $derived(getUser());
|
||||||
@@ -20,8 +19,10 @@
|
|||||||
|
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
|
<div class="brand">
|
||||||
<span class="app-name">Journi</span>
|
<span class="app-name">Journi</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="segmented">
|
<div class="segmented">
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
|
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
|
||||||
></div>
|
></div>
|
||||||
<button class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Worldmap</button>
|
<button class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Worldmap</button>
|
||||||
<button class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Timeline</button>
|
<button class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Journal</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,6 +84,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
font-family: var(--heading);
|
font-family: var(--heading);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
@@ -130,7 +137,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 300;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
|||||||
@@ -1,60 +1,25 @@
|
|||||||
import { db } from '../firebase.js';
|
import { journals } from '../stores/entriesStore.svelte.js';
|
||||||
import { doc, onSnapshot, setDoc, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
|
import { nameToId } from '../shared/countries.js';
|
||||||
|
import { getUserProfile } from '../auth/userStore.svelte.js';
|
||||||
|
|
||||||
let selected = $state(new Set());
|
let selected = $state(new Set());
|
||||||
let totalCountries = $state(0);
|
let totalCountries = $state(0);
|
||||||
let homeCountryCode = $state(null);
|
let flashing = $state(new Set());
|
||||||
let _uid = null;
|
|
||||||
let _unsubscribe = null;
|
|
||||||
|
|
||||||
export function initSelectionListener(uid) {
|
journals.subscribe((entries) => {
|
||||||
if (_unsubscribe) _unsubscribe();
|
const ids = new Set();
|
||||||
_uid = uid;
|
for (const e of entries) {
|
||||||
const userRef = doc(db, 'users', uid);
|
const id = nameToId[e.location?.country];
|
||||||
_unsubscribe = onSnapshot(userRef, (snap) => {
|
if (id) ids.add(id);
|
||||||
if (snap.exists()) {
|
|
||||||
const codes = snap.data().visitedCountries || [];
|
|
||||||
selected = new Set(codes);
|
|
||||||
homeCountryCode = snap.data().homeCountryCode || null;
|
|
||||||
}
|
}
|
||||||
});
|
const profile = getUserProfile();
|
||||||
}
|
if (profile?.homeCountry) {
|
||||||
|
const homeId = nameToId[profile.homeCountry];
|
||||||
const visitedRef = doc(db, 'visited', 'countries');
|
if (homeId) ids.add(homeId);
|
||||||
|
|
||||||
onSnapshot(visitedRef, (snap) => {
|
|
||||||
if (snap.exists()) {
|
|
||||||
selected = new Set(snap.data().ids ?? []);
|
|
||||||
}
|
}
|
||||||
|
selected = ids;
|
||||||
});
|
});
|
||||||
|
|
||||||
function persist() {
|
|
||||||
setDoc(visitedRef, { ids: [...selected] });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggle(id) {
|
|
||||||
const was = selected.has(id);
|
|
||||||
const next = new Set(selected);
|
|
||||||
if (was) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
selected = next;
|
|
||||||
persist();
|
|
||||||
if (_uid) {
|
|
||||||
const userRef = doc(db, 'users', _uid);
|
|
||||||
if (was) updateDoc(userRef, { visitedCountries: arrayRemove(id) });
|
|
||||||
else updateDoc(userRef, { visitedCountries: arrayUnion(id) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAll() {
|
|
||||||
selected = new Set();
|
|
||||||
persist();
|
|
||||||
if (_uid) {
|
|
||||||
const userRef = doc(db, 'users', _uid);
|
|
||||||
updateDoc(userRef, { visitedCountries: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSelected() {
|
export function getSelected() {
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
@@ -67,6 +32,15 @@ export function getTotalCount() {
|
|||||||
return totalCountries;
|
return totalCountries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHomeCountryCode() {
|
export function getFlashing() {
|
||||||
return homeCountryCode;
|
return flashing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flashCountry(countryName) {
|
||||||
|
const id = nameToId[countryName];
|
||||||
|
if (!id) return;
|
||||||
|
flashing = new Set([...flashing, id]);
|
||||||
|
setTimeout(() => {
|
||||||
|
flashing = new Set([...flashing].filter(x => x !== id));
|
||||||
|
}, 1600);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Searchable combobox input.
|
* Searchable combobox input.
|
||||||
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
|
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
|
||||||
*/
|
*/
|
||||||
let { id, value = $bindable(), options, placeholder = '', required = false, onselect } = $props();
|
let { id, value = $bindable(), options, placeholder = '', required = false, onselect, onblurcommit } = $props();
|
||||||
|
|
||||||
let query = $state(value);
|
let query = $state(value);
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -39,7 +39,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBlur() {
|
function onBlur() {
|
||||||
setTimeout(() => { open = false; focused = -1; }, 150);
|
setTimeout(() => {
|
||||||
|
open = false;
|
||||||
|
focused = -1;
|
||||||
|
if (onblurcommit && query.trim()) onblurcommit(query.trim());
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep query in sync if value is changed externally
|
// Keep query in sync if value is changed externally
|
||||||
|
|||||||
@@ -63,8 +63,18 @@ const nameToAlpha2 = {
|
|||||||
'eSwatini':'SZ','Åland':'AX',
|
'eSwatini':'SZ','Åland':'AX',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const countryNames = feature(worldData, worldData.objects.countries)
|
const _features = feature(worldData, worldData.objects.countries).features;
|
||||||
.features.map(f => f.properties?.name).filter(Boolean).sort();
|
|
||||||
|
export const countryNames = _features
|
||||||
|
.map(f => f.properties?.name).filter(Boolean).sort();
|
||||||
|
|
||||||
|
// country name → topojson numeric ID (e.g. 'Japan' → '392')
|
||||||
|
export const nameToId = Object.fromEntries(
|
||||||
|
_features
|
||||||
|
.filter(f => f.properties?.name && f.id)
|
||||||
|
.map(f => [f.properties.name, String(f.id)])
|
||||||
|
);
|
||||||
|
nameToId['Kosovo'] = 'XK';
|
||||||
|
|
||||||
/** @param {string} country */
|
/** @param {string} country */
|
||||||
export function flagEmoji(country) {
|
export function flagEmoji(country) {
|
||||||
|
|||||||
15
src/lib/shared/types.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* id: string,
|
||||||
|
* title: string,
|
||||||
|
* date: string,
|
||||||
|
* location: { country: string, cities: string[] },
|
||||||
|
* photos: string[],
|
||||||
|
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
|
||||||
|
* tripType: 'solo' | 'friends' | 'family',
|
||||||
|
* days: number,
|
||||||
|
* memo: string
|
||||||
|
* }} JournalEntry
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { db } from '../firebase.js';
|
import { db } from '../firebase.js';
|
||||||
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
|
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
let entries = $state([]);
|
let entries = $state([]);
|
||||||
let _uid = null;
|
let _uid = null;
|
||||||
let _unsubscribe = null;
|
let _unsubscribe = null;
|
||||||
|
|
||||||
|
export const journals = writable([]);
|
||||||
|
|
||||||
export function getEntries() {
|
export function getEntries() {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
@@ -17,12 +20,14 @@ export function initEntriesListener(uid) {
|
|||||||
orderBy('createdAt', 'desc')
|
orderBy('createdAt', 'desc')
|
||||||
);
|
);
|
||||||
_unsubscribe = onSnapshot(q, (snap) => {
|
_unsubscribe = onSnapshot(q, (snap) => {
|
||||||
entries = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
const data = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
||||||
|
entries = data;
|
||||||
|
journals.set(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addEntry(data) {
|
export async function addEntry(data) {
|
||||||
if (!_uid) return null;
|
if (!_uid) throw new Error('Not logged in');
|
||||||
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
|
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
|
||||||
...data,
|
...data,
|
||||||
createdAt: serverTimestamp(),
|
createdAt: serverTimestamp(),
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import { db } from '../firebase.js';
|
|
||||||
import {
|
|
||||||
collection, onSnapshot, addDoc, updateDoc, deleteDoc, doc, serverTimestamp
|
|
||||||
} from 'firebase/firestore';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {{
|
|
||||||
* id: string,
|
|
||||||
* title: string,
|
|
||||||
* date: string,
|
|
||||||
* location: { country: string, cities: string[] },
|
|
||||||
* photos: string[],
|
|
||||||
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
|
|
||||||
* tripType: 'solo' | 'friends' | 'family',
|
|
||||||
* days: number,
|
|
||||||
* memo: string
|
|
||||||
* }} JournalEntry
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const journals = writable(/** @type {JournalEntry[]} */([]));
|
|
||||||
export const journalsLoading = writable(true);
|
|
||||||
|
|
||||||
const entriesRef = collection(db, 'entries');
|
|
||||||
|
|
||||||
onSnapshot(entriesRef, (snap) => {
|
|
||||||
journals.set(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
|
||||||
journalsLoading.set(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @param {Omit<JournalEntry, 'id'>} entry */
|
|
||||||
export async function addJournal(entry) {
|
|
||||||
await addDoc(entriesRef, { ...entry, createdAt: serverTimestamp() });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} id */
|
|
||||||
export async function removeJournal(id) {
|
|
||||||
await deleteDoc(doc(db, 'entries', id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {JournalEntry} updated */
|
|
||||||
export async function updateJournal(updated) {
|
|
||||||
const { id, ...data } = updated;
|
|
||||||
await updateDoc(doc(db, 'entries', id), data);
|
|
||||||
}
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
<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';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* entry = null → "new entry" mode
|
|
||||||
* entry = {...} → "edit" mode
|
|
||||||
* @type {{ entry?: import('../stores/journalStore.js').JournalEntry | null, initialCountry?: string, onBack: () => void }}
|
|
||||||
*/
|
|
||||||
let { entry = null, initialCountry = '', onBack } = $props();
|
|
||||||
|
|
||||||
let isNew = !entry;
|
|
||||||
|
|
||||||
let cities = $state([...(entry?.location.cities ?? [])]);
|
|
||||||
let cityInput = $state('');
|
|
||||||
let country = $state(entry?.location.country ?? initialCountry);
|
|
||||||
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
|
||||||
let days = $state(String(entry?.days ?? ''));
|
|
||||||
let tripType = $state(entry?.tripType ?? '');
|
|
||||||
let photos = $state([...(entry?.photos ?? [])]);
|
|
||||||
let memo = $state(entry?.memo ?? '');
|
|
||||||
let transport = $state(entry?.transport ?? '');
|
|
||||||
|
|
||||||
let errors = $state({
|
|
||||||
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
function clearErrors() {
|
|
||||||
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);
|
|
||||||
|
|
||||||
function onMemoInput(e) {
|
|
||||||
const raw = e.currentTarget.value;
|
|
||||||
const words = raw.trim() === '' ? [] : raw.trim().split(/\s+/);
|
|
||||||
if (words.length > MEMO_MAX) {
|
|
||||||
// keep first 100 words, preserve trailing space if user is mid-word
|
|
||||||
memo = words.slice(0, MEMO_MAX).join(' ');
|
|
||||||
e.currentTarget.value = memo;
|
|
||||||
} else {
|
|
||||||
memo = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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];
|
|
||||||
}
|
|
||||||
cityInput = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCity(c) {
|
|
||||||
cities = cities.filter(x => x !== c);
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
title: `${cities.join(', ')}, ${country}`,
|
|
||||||
date,
|
|
||||||
days: Number(days),
|
|
||||||
tripType,
|
|
||||||
memo,
|
|
||||||
photos,
|
|
||||||
transport,
|
|
||||||
location: { cities, country },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateEntry(entry.id, {
|
|
||||||
date,
|
|
||||||
days: Number(days),
|
|
||||||
tripType,
|
|
||||||
transport,
|
|
||||||
memo,
|
|
||||||
photos,
|
|
||||||
location: { cities, country },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onBack();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="edit-layout">
|
|
||||||
|
|
||||||
<header class="edit-topbar">
|
|
||||||
<div class="topbar-left">
|
|
||||||
<button class="topbar-btn" onclick={onBack}>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
|
||||||
</svg>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span class="topbar-title">{isNew ? 'New trip' : 'Edit'}</span>
|
|
||||||
<div class="topbar-right">
|
|
||||||
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="edit-scroll">
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="edit-country">Country <span class="req">*</span></label>
|
|
||||||
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
|
|
||||||
{#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>
|
|
||||||
{#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}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="edit-date">Date <span class="req">*</span></label>
|
|
||||||
<input id="edit-date" class="input" type="date" bind:value={date} required />
|
|
||||||
{#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>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.field-error {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #dc2626;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 20px;
|
|
||||||
height: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 36px 48px 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-sub);
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
.char-count.over { color: #dc2626; }
|
|
||||||
.input-over { border-color: #fca5a5; }
|
|
||||||
|
|
||||||
.req {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-h);
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.input:focus { border-color: var(--accent-border); }
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
resize: vertical;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-opt {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 7px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
}
|
|
||||||
.toggle-opt input { display: none; }
|
|
||||||
.toggle-opt.active {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transport-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.transport-opt {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.transport-opt input { display: none; }
|
|
||||||
.transport-opt.active {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
<script>
|
|
||||||
/** @type {{ entries: import('../stores/journalStore.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,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>
|
|
||||||
208
src/lib/timeline/detail/EditForm.svelte
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<script>
|
||||||
|
import { addEntry, updateEntry } from '../../stores/entriesStore.svelte.js';
|
||||||
|
import PhotoEditor from './PhotoEditor.svelte';
|
||||||
|
import StepNav from './StepNavbar.svelte';
|
||||||
|
import TripBasicInfo from './TripBasicInfo.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* entry = null → "new entry" mode
|
||||||
|
* entry = {...} → "edit" mode
|
||||||
|
* @type {{ entry?: import('../shared/types.js').JournalEntry | null, initialCountry?: string, onBack: () => void }}
|
||||||
|
*/
|
||||||
|
let { entry = null, initialCountry = '', onBack } = $props();
|
||||||
|
|
||||||
|
let isNew = !entry;
|
||||||
|
|
||||||
|
let cities = $state([...(entry?.location.cities ?? [])]);
|
||||||
|
let country = $state(entry?.location.country ?? initialCountry);
|
||||||
|
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
||||||
|
let days = $state(String(entry?.days ?? ''));
|
||||||
|
let tripType = $state(entry?.tripType ?? '');
|
||||||
|
let photos = $state([...(entry?.photos ?? [])]);
|
||||||
|
let memo = $state(entry?.memo ?? '');
|
||||||
|
let transport = $state(entry?.transport ?? '');
|
||||||
|
|
||||||
|
let step = $state(1);
|
||||||
|
|
||||||
|
let errors = $state({
|
||||||
|
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMO_MAX = 100;
|
||||||
|
let wordCount = $derived(memo.trim() === '' ? 0 : memo.trim().split(/\s+/).length);
|
||||||
|
let memoOverLimit = $derived(wordCount > MEMO_MAX);
|
||||||
|
|
||||||
|
function onMemoInput(e) {
|
||||||
|
const raw = e.currentTarget.value;
|
||||||
|
const words = raw.trim() === '' ? [] : raw.trim().split(/\s+/);
|
||||||
|
if (words.length > MEMO_MAX) {
|
||||||
|
memo = words.slice(0, MEMO_MAX).join(' ');
|
||||||
|
e.currentTarget.value = memo;
|
||||||
|
} else {
|
||||||
|
memo = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStep() {
|
||||||
|
if (step === 1) {
|
||||||
|
clearErrors();
|
||||||
|
let hasError = false;
|
||||||
|
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
|
||||||
|
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
|
||||||
|
if (!date) { errors.date = 'Date is required.'; hasError = true; }
|
||||||
|
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
|
||||||
|
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
|
||||||
|
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
|
||||||
|
if (hasError) return;
|
||||||
|
}
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
if (step === 1) onBack();
|
||||||
|
else step--;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await addEntry({
|
||||||
|
title: `${cities.join(', ')}, ${country}`,
|
||||||
|
date,
|
||||||
|
days: Number(days),
|
||||||
|
tripType,
|
||||||
|
memo,
|
||||||
|
photos,
|
||||||
|
transport,
|
||||||
|
location: { cities, country },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await updateEntry(entry.id, {
|
||||||
|
date,
|
||||||
|
days: Number(days),
|
||||||
|
tripType,
|
||||||
|
transport,
|
||||||
|
memo,
|
||||||
|
photos,
|
||||||
|
location: { cities, country },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onBack();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = $derived(step < 3 ? nextStep : save);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<StepNav {step} totalSteps={3} onback={prevStep} onnext={next} />
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<div class="form">
|
||||||
|
|
||||||
|
{#if step === 1}
|
||||||
|
<TripBasicInfo
|
||||||
|
bind:country bind:cities
|
||||||
|
bind:date bind:days bind:tripType bind:transport
|
||||||
|
bind:errors {isNew}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{:else if step === 2}
|
||||||
|
<h2 class="step-title">Photos</h2>
|
||||||
|
<p class="step-sub">Optional — add or update photos from your trip</p>
|
||||||
|
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<h2 class="step-title">How was it?</h2>
|
||||||
|
<p class="step-sub">Optional — write a note about your trip</p>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label-row">
|
||||||
|
<label class="label" for="edit-memo">Your notes</label>
|
||||||
|
<span class="char-count" class:over={memoOverLimit}>{wordCount} / {MEMO_MAX} words</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="edit-memo" class="input textarea" class:input-over={memoOverLimit} rows="8" value={memo} oninput={onMemoInput}></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
.form {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 36px 48px 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
.step-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-sub);
|
||||||
|
margin: -10px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count { font-size: 11px; font-weight: 300; color: var(--text-sub); transition: color 0.15s; }
|
||||||
|
.char-count.over { color: #dc2626; }
|
||||||
|
.input-over { border-color: #fca5a5; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-h);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--accent-border); }
|
||||||
|
|
||||||
|
.textarea { resize: vertical; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { removeEntry } from '../stores/entriesStore.svelte.js';
|
import { removeEntry } from '../../stores/entriesStore.svelte.js';
|
||||||
import { flagEmoji } from '../shared/countries.js';
|
import { flagEmoji } from '../../shared/countries.js';
|
||||||
import DeleteConfirm from './DeleteConfirm.svelte';
|
import DeleteConfirm from './DeleteConfirm.svelte';
|
||||||
|
|
||||||
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
|
/** @type {{ entry: import('../shared/types.js').JournalEntry, onBack: () => void, onEdit: () => void }} */
|
||||||
let { entry, onBack, onEdit } = $props();
|
let { entry, onBack, onEdit } = $props();
|
||||||
|
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
@@ -1,24 +1,37 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getEntries } from '../stores/entriesStore.svelte.js';
|
import { journals, addEntry } from '../../stores/entriesStore.svelte.js';
|
||||||
import { addEntry } from '../stores/entriesStore.svelte.js';
|
import { get } from 'svelte/store';
|
||||||
import { countryNames } from '../shared/countries.js';
|
import { flashCountry } from '../../layout/selection.svelte.js';
|
||||||
import { getCitiesForCountry, ALL_CITIES } from '../shared/cities.js';
|
import { countryNames } from '../../shared/countries.js';
|
||||||
import SearchInput from '../shared/SearchInput.svelte';
|
import { ALL_CITIES } from '../../shared/cities.js';
|
||||||
|
import SearchInput from '../../shared/SearchInput.svelte';
|
||||||
import PhotoEditor from './PhotoEditor.svelte';
|
import PhotoEditor from './PhotoEditor.svelte';
|
||||||
import airplaneImg from '../../assets/airplane.png';
|
import StepNav from './StepNavbar.svelte';
|
||||||
import trainImg from '../../assets/train.png';
|
import airplaneImg from '../../../assets/airplane.png';
|
||||||
import busImg from '../../assets/bus.png';
|
import trainImg from '../../../assets/train.png';
|
||||||
import carImg from '../../assets/car.png';
|
import busImg from '../../../assets/bus.png';
|
||||||
import shipImg from '../../assets/ship.png';
|
import carImg from '../../../assets/car.png';
|
||||||
import walkImg from '../../assets/walk.png';
|
import shipImg from '../../../assets/ship.png';
|
||||||
|
import walkImg from '../../../assets/walk.png';
|
||||||
|
|
||||||
let { initialCountry = '', onBack } = $props();
|
let { initialCountry = '', onBack, onSaved = onBack } = $props();
|
||||||
|
|
||||||
|
// ── Journal store (reactive) ────────────────────────────────────────
|
||||||
|
let journalEntries = $state(get(journals));
|
||||||
|
$effect(() => {
|
||||||
|
const unsub = journals.subscribe(v => { journalEntries = v; });
|
||||||
|
return unsub;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Fields ─────────────────────────────────────────────────────────
|
// ── Fields ─────────────────────────────────────────────────────────
|
||||||
let cities = $state([]);
|
let cities = $state([]);
|
||||||
let cityInput = $state('');
|
let cityInput = $state('');
|
||||||
let country = $state(initialCountry);
|
let country = $state('');
|
||||||
let date = $state(new Date().toISOString().slice(0, 10));
|
let date = $state(new Date().toISOString().slice(0, 10));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
country = initialCountry;
|
||||||
|
});
|
||||||
let days = $state('');
|
let days = $state('');
|
||||||
let tripType = $state('');
|
let tripType = $state('');
|
||||||
let transport = $state('');
|
let transport = $state('');
|
||||||
@@ -54,11 +67,13 @@
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
// Suggest cities — if a country is selected, show cities only from that country;
|
// Suggest cities — if a country is selected, show cities only from that country;
|
||||||
// otherwise show all known cities.
|
// otherwise show all known cities.
|
||||||
let allEntries = $derived(getEntries());
|
|
||||||
let cityOptions = $derived(
|
let cityOptions = $derived(
|
||||||
country.trim()
|
country.trim()
|
||||||
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
|
? [...new Set([
|
||||||
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
|
...(ALL_CITIES[country.trim()] ?? []),
|
||||||
|
...journalEntries.filter(j => (j.location?.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location?.cities ?? []),
|
||||||
|
])]
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
function addCity(val) {
|
function addCity(val) {
|
||||||
@@ -108,9 +123,11 @@
|
|||||||
|
|
||||||
// ── Save ───────────────────────────────────────────────────────────
|
// ── Save ───────────────────────────────────────────────────────────
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let saveError = $state('');
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving = true;
|
saving = true;
|
||||||
|
saveError = '';
|
||||||
const memo = questions
|
const memo = questions
|
||||||
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
|
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -126,40 +143,19 @@
|
|||||||
photos,
|
photos,
|
||||||
location: { cities, country },
|
location: { cities, country },
|
||||||
});
|
});
|
||||||
onBack();
|
flashCountry(country);
|
||||||
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Save failed:', e);
|
|
||||||
saving = false;
|
saving = false;
|
||||||
alert('Failed to save. Check console for details.');
|
saveError = e?.message ?? 'Failed to save. Please try again.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let next = $derived(step < 3 ? nextStep : save);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<header class="topbar">
|
<StepNav {step} onback={prevStep} onnext={next} {saving} saveLabel="Save trip" {saveError} />
|
||||||
<div class="topbar-left">
|
|
||||||
<button class="ghost-btn" onclick={prevStep}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
|
||||||
{step === 1 ? 'Back' : 'Previous'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="steps">
|
|
||||||
{#each [1,2,3] as s}
|
|
||||||
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topbar-right">
|
|
||||||
{#if step < 3}
|
|
||||||
<button class="save-btn" onclick={nextStep}>Next</button>
|
|
||||||
{:else}
|
|
||||||
<button class="save-btn" onclick={save} disabled={saving}>
|
|
||||||
{saving ? 'Saving…' : 'Save trip'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div class="form">
|
<div class="form">
|
||||||
@@ -174,9 +170,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- ── STEP 1: Details ── -->
|
|
||||||
<h2 class="step-title">Trip details</h2>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="nc-country">Which <span class="kw">country</span> did you visit? <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>
|
||||||
@@ -216,7 +209,7 @@
|
|||||||
{#each ['solo','friends','family'] as t}
|
{#each ['solo','friends','family'] as t}
|
||||||
<label class="toggle-opt" class:active={tripType === t}>
|
<label class="toggle-opt" class:active={tripType === t}>
|
||||||
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
|
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
|
||||||
{t === 'solo' ? '🧑 Solo' : t === 'friends' ? '👥 With friends' : '👨👩👧👦 With family'}
|
{t === 'solo' ? 'Solo' : t === 'friends' ? 'With friends' : 'With family'}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +247,7 @@
|
|||||||
|
|
||||||
{#each questions as q, i}
|
{#each questions as q, i}
|
||||||
<div class="q-card">
|
<div class="q-card">
|
||||||
<p class="q-text">{q}{country.trim() ? ` in ${country}` : ''}</p>
|
<p class="q-text">{q}</p>
|
||||||
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
|
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -273,78 +266,6 @@
|
|||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* topbar */
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 20px;
|
|
||||||
height: 52px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
.topbar-left, .topbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 110px;
|
|
||||||
}
|
|
||||||
.topbar-right { justify-content: flex-end; }
|
|
||||||
|
|
||||||
.steps {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.step-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--border);
|
|
||||||
transition: background 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
.step-dot.active {
|
|
||||||
background: var(--accent);
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
.step-dot.done {
|
|
||||||
background: var(--accent);
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghost-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #fff;
|
|
||||||
background: var(--accent);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
|
||||||
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* scroll + form */
|
/* scroll + form */
|
||||||
.scroll { flex: 1; overflow-y: auto; }
|
.scroll { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
@@ -412,22 +333,23 @@
|
|||||||
}
|
}
|
||||||
.input:focus { border-color: var(--accent-border); }
|
.input:focus { border-color: var(--accent-border); }
|
||||||
|
|
||||||
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
.toggle-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.toggle-opt {
|
.toggle-opt {
|
||||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||||
font-size: 16px; font-weight: 400; color: var(--text);
|
font-size: 14px; font-weight: 400; color: var(--text);
|
||||||
padding: 12px 14px; border-radius: 10px;
|
padding: 16px 10px; border-radius: 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.toggle-opt input { display: none; }
|
.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 { 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%); }
|
.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(4, 1fr); gap: 8px; }
|
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
.transport-img { width: 30px; height: 30px; object-fit: contain; flex-shrink: 0; }
|
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
|
||||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
.tag {
|
.tag {
|
||||||
@@ -454,8 +376,8 @@
|
|||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||||
}
|
}
|
||||||
.q-text {
|
.q-text {
|
||||||
font-size: 20px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { storage } from '../firebase.js';
|
import { storage } from '../../firebase.js';
|
||||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||||
|
|
||||||
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
|
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
|
||||||
@@ -7,18 +7,23 @@
|
|||||||
|
|
||||||
let fileInput;
|
let fileInput;
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
|
let uploadError = $state('');
|
||||||
|
|
||||||
function remove(index) {
|
function remove(index) {
|
||||||
onchange(photos.filter((_, i) => i !== index));
|
onchange(photos.filter((_, i) => i !== index));
|
||||||
|
uploadError = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFiles(e) {
|
async function addFiles(e) {
|
||||||
const files = Array.from(e.currentTarget.files ?? []);
|
const files = Array.from(e.currentTarget.files ?? []);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
uploading = true;
|
uploading = true;
|
||||||
|
uploadError = '';
|
||||||
try {
|
try {
|
||||||
const urls = await Promise.all(files.map(uploadPhoto));
|
const urls = await Promise.all(files.map(uploadPhoto));
|
||||||
onchange([...photos, ...urls]);
|
onchange([...photos, ...urls]);
|
||||||
|
} catch (err) {
|
||||||
|
uploadError = err?.message ?? 'Upload failed. Check Firebase Storage rules.';
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
e.currentTarget.value = '';
|
e.currentTarget.value = '';
|
||||||
@@ -68,6 +73,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadError}
|
||||||
|
<div class="upload-error">{uploadError}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -183,4 +192,15 @@
|
|||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
|
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
|
||||||
|
|
||||||
|
.upload-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
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">
|
||||||
|
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
|
||||||
|
<div class="toggle-row">
|
||||||
|
{#each ['solo','friends','family'] as t}
|
||||||
|
<label class="toggle-opt" class:active={tripType === t}>
|
||||||
|
<input type="radio" name="tbi-tripType" value={t} bind:group={tripType} />
|
||||||
|
{t === 'solo' ? '🧑 Solo' : t === 'friends' ? '👥 With friends' : '👨👩👧👦 With family'}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
|
||||||
|
<div class="transport-grid">
|
||||||
|
{#each transportOptions as opt}
|
||||||
|
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
|
||||||
|
<input type="radio" name="tbi-transport" value={opt.value} bind:group={transport} />
|
||||||
|
<img src={opt.img} alt={opt.label} class="transport-img" />
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-headline {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
.req { color: var(--accent); font-size: 11px; }
|
||||||
|
.kw { color: var(--accent); }
|
||||||
|
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-h);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--accent-border); }
|
||||||
|
|
||||||
|
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.toggle-opt {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
font-size: 14px; font-weight: 400; color: var(--text);
|
||||||
|
padding: 12px 14px; border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle-opt input { display: none; }
|
||||||
|
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
|
||||||
|
|
||||||
|
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.transport-opt { flex-direction: column; gap: 6px; padding: 16px 10px; }
|
||||||
|
.transport-img { width: 44px; height: 44px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 12px; font-weight: 300; color: var(--accent);
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--accent-border);
|
||||||
|
border-radius: 20px; padding: 3px 10px 3px 12px;
|
||||||
|
}
|
||||||
|
.tag-rm {
|
||||||
|
background: none; border: none; color: var(--accent);
|
||||||
|
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
|
||||||
|
}
|
||||||
|
.tag-rm:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { toPng } from 'html-to-image';
|
import { toPng } from 'html-to-image';
|
||||||
|
import { getTotalCount } from '../../layout/selection.svelte.js';
|
||||||
|
import profileImg from '../../../assets/profile.png';
|
||||||
|
|
||||||
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClose: () => void }} */
|
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClose: () => void }} */
|
||||||
let { entries, onClose } = $props();
|
let { entries, onClose } = $props();
|
||||||
|
|
||||||
let cardEl = $state(null);
|
let cardEl = $state(null);
|
||||||
@@ -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() {
|
async function download() {
|
||||||
if (!cardEl) return;
|
if (!cardEl) return;
|
||||||
downloading = true;
|
downloading = true;
|
||||||
try {
|
try {
|
||||||
const dataUrl = await toPng(cardEl, { pixelRatio: 3 });
|
await document.fonts.ready;
|
||||||
|
|
||||||
|
let fontFaceRule = '';
|
||||||
|
try {
|
||||||
|
const fontData = await getFontDataUrl();
|
||||||
|
if (fontData) {
|
||||||
|
fontFaceRule = `@font-face { font-family: 'Bricolage Grotesque'; src: url('${fontData}') format('woff2'); font-weight: 100 900; font-style: normal; }`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Inject font as a real <style> inside the card so html-to-image clones it
|
||||||
|
let injected = null;
|
||||||
|
if (fontFaceRule) {
|
||||||
|
injected = document.createElement('style');
|
||||||
|
injected.textContent = fontFaceRule;
|
||||||
|
cardEl.prepend(injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = { pixelRatio: 3 };
|
||||||
|
await toPng(cardEl, opts); // first pass loads resources
|
||||||
|
const dataUrl = await toPng(cardEl, opts);
|
||||||
|
|
||||||
|
if (injected) injected.remove();
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.download = 'my-journey.png';
|
a.download = 'my-journey.png';
|
||||||
a.href = dataUrl;
|
a.href = dataUrl;
|
||||||
@@ -135,35 +185,22 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-brand">MAP JOURNAL</span>
|
<span class="card-brand">JOURNI</span>
|
||||||
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
|
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<div class="profile-wrap">
|
||||||
|
<img class="profile-img" src={profileImg} alt="profile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hero stat -->
|
<!-- Hero stat -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<p class="hero-num">{stats.totalDays}</p>
|
<p class="hero-pre">You've colored</p>
|
||||||
<p class="hero-label">days of travel</p>
|
<p class="big-num">{getTotalCount() > 0 ? Math.round((stats.countries.length / getTotalCount()) * 100) : 0}%</p>
|
||||||
|
<p class="hero-post">of the world map.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stat grid -->
|
|
||||||
<div class="stat-grid">
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{stats.countries.length}</p>
|
|
||||||
<p class="stat-desc">countries</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{stats.cities.length}</p>
|
|
||||||
<p class="stat-desc">cities</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{stats.flightHrs}h</p>
|
|
||||||
<p class="stat-desc">in the air</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-box">
|
|
||||||
<p class="stat-num">{Object.keys(stats.contDays).length}</p>
|
|
||||||
<p class="stat-desc">continents</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fun facts -->
|
<!-- Fun facts -->
|
||||||
<div class="facts">
|
<div class="facts">
|
||||||
@@ -197,29 +234,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Continent bar -->
|
|
||||||
{#if Object.keys(stats.contDays).length > 0}
|
|
||||||
<div class="cont-section">
|
|
||||||
<div class="cont-bar">
|
|
||||||
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
|
|
||||||
<div class="cont-seg" style="flex:{days}; background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"
|
|
||||||
title="{cont}: {days}d"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="cont-legend">
|
|
||||||
{#each Object.entries(stats.contDays).sort((a,b)=>b[1]-a[1]) as [cont, days]}
|
|
||||||
<span class="cont-item">
|
|
||||||
<span class="cont-dot" style="background: var(--cont-{cont.replace('. ','').toLowerCase().replace(' ','-')}, #818cf8)"></span>
|
|
||||||
{cont} {days}d
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span>mapjournal.app</span>
|
<span>journi</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -352,25 +370,41 @@
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Profile */
|
||||||
|
.profile-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.profile-img {
|
||||||
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hero */
|
/* Hero */
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.hero-num {
|
.hero-pre, .hero-post {
|
||||||
font-size: 88px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -4px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.hero-label {
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: #a5b4fc;
|
color: rgba(255,255,255,0.6);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-top: 4px;
|
margin: 0;
|
||||||
|
}
|
||||||
|
.big-num {
|
||||||
|
font-size: 88px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -4px;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stat grid */
|
/* Stat grid */
|
||||||
@@ -388,6 +422,10 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.stat-num {
|
.stat-num {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClick: () => void }} */
|
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClick: () => void }} */
|
||||||
let { entries, onClick } = $props();
|
let { entries, onClick } = $props();
|
||||||
|
|
||||||
const continentMap = {
|
const continentMap = {
|
||||||
@@ -42,10 +42,6 @@
|
|||||||
|
|
||||||
<div class="pc-header">
|
<div class="pc-header">
|
||||||
<span class="pc-brand">MAP JOURNAL</span>
|
<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>
|
||||||
|
|
||||||
<div class="pc-hero">
|
<div class="pc-hero">
|
||||||
@@ -83,7 +79,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</button>
|
||||||
|
|
||||||
@@ -143,7 +142,13 @@
|
|||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
color: #a5b4fc;
|
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 {
|
.pc-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -231,5 +236,6 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
border-top: 1px solid rgba(255,255,255,0.08);
|
border-top: 1px solid rgba(255,255,255,0.08);
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
<script>
|
<script>
|
||||||
import { flagEmoji } from '../shared/countries.js';
|
import { flagEmoji } from '../../shared/countries.js';
|
||||||
|
import default1 from '../../../assets/default-1.jpeg';
|
||||||
|
import default2 from '../../../assets/default-2.jpeg';
|
||||||
|
import default3 from '../../../assets/default-3.jpeg';
|
||||||
|
|
||||||
/** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onClick: () => void }} */
|
/** @type {{ entry: import('../shared/types.js').JournalEntry, onClick: () => void }} */
|
||||||
let { entry, onClick } = $props();
|
let { entry, onClick } = $props();
|
||||||
|
|
||||||
|
const defaults = [default1, default2, default3];
|
||||||
|
|
||||||
function formatDate(/** @type {string} */ iso) {
|
function formatDate(/** @type {string} */ iso) {
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainPhoto = $derived(entry.photos[0] ?? null);
|
// Pick a stable random default based on the entry id
|
||||||
|
function defaultPhoto(id) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return defaults[hash % defaults.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainPhoto = $derived(entry.photos[0] ?? defaultPhoto(entry.id));
|
||||||
let thumbPhotos = $derived(entry.photos.slice(1, 4));
|
let thumbPhotos = $derived(entry.photos.slice(1, 4));
|
||||||
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
|
let extraCount = $derived(entry.photos.length > 4 ? entry.photos.length - 4 : 0);
|
||||||
|
|
||||||
@@ -29,10 +41,11 @@
|
|||||||
<div class="v-dot" aria-hidden="true"></div>
|
<div class="v-dot" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="v-content">
|
<div class="v-content">
|
||||||
<!-- Country above card -->
|
<!-- Country + cities above card -->
|
||||||
<div class="above-card">
|
<div class="above-card">
|
||||||
<span class="flag">{flagEmoji(entry.location.country)}</span>
|
<span class="flag">{flagEmoji(entry.location.country)}</span>
|
||||||
<span class="country-name">{entry.location.country}</span>
|
<span class="country-name">{entry.location.country}</span>
|
||||||
|
<span class="city-inline">· {entry.location.cities.join(', ')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
@@ -40,36 +53,10 @@
|
|||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onClick()}>
|
onkeydown={(e) => e.key === 'Enter' && onClick()}>
|
||||||
|
|
||||||
<!-- Trip badge — top-right of card, outside photo -->
|
|
||||||
<span class="trip-badge trip-badge--{entry.tripType}">
|
|
||||||
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Photos -->
|
<!-- Photos -->
|
||||||
<div class="photo-grid" class:has-thumbs={thumbPhotos.length > 0}>
|
<div class="photo-grid" class:has-thumbs={thumbPhotos.length > 0}>
|
||||||
<div class="photo-main">
|
<div class="photo-main">
|
||||||
{#if mainPhoto}
|
<img src={mainPhoto} alt="" loading="lazy" />
|
||||||
<img src={mainPhoto} alt="" loading="lazy"
|
|
||||||
onerror={(e) => {
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
e.currentTarget.nextElementSibling.style.display = 'flex';
|
|
||||||
}} />
|
|
||||||
<div class="photo-fallback" style="display:none">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
||||||
<path d="M21 15l-5-5L5 21"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="photo-fallback">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
||||||
<path d="M21 15l-5-5L5 21"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if thumbPhotos.length > 0}
|
{#if thumbPhotos.length > 0}
|
||||||
@@ -99,18 +86,17 @@
|
|||||||
|
|
||||||
<!-- Info bar -->
|
<!-- Info bar -->
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<span class="city">{entry.location.cities.join(', ')}</span>
|
<span class="days-label">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if entry.transport}
|
{#if entry.transport}
|
||||||
<span class="transport-chip transport-chip--{entry.transport}">
|
<span class="transport-chip transport-chip--{entry.transport}">
|
||||||
{@html transportIcons[entry.transport] ?? ''}
|
{@html transportIcons[entry.transport] ?? ''}
|
||||||
{transportLabel}
|
{transportLabel}
|
||||||
</span>
|
</span>
|
||||||
<span class="dot-sep">·</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<span>{formatDate(entry.date)}</span>
|
<span class="trip-badge trip-badge--{entry.tripType}">
|
||||||
<span class="dot-sep">·</span>
|
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
|
||||||
<span>{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,6 +158,14 @@
|
|||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
}
|
}
|
||||||
|
.city-inline {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-sub);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card ── */
|
/* ── Card ── */
|
||||||
.entry-card {
|
.entry-card {
|
||||||
@@ -191,22 +185,18 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Trip badge — absolute top-right of card ── */
|
/* ── Trip badge — inline in info bar ── */
|
||||||
.trip-badge {
|
.trip-badge {
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 2;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
padding: 3px 10px;
|
padding: 2px 8px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
backdrop-filter: blur(6px);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.trip-badge--solo { background: rgba(245,158,11,0.85); color: #fff; }
|
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; border: 1px solid rgba(245,158,11,0.25); }
|
||||||
.trip-badge--friends { background: rgba(124,58,237,0.85); color: #fff; }
|
.trip-badge--friends { background: rgba(124,58,237,0.07); color: #7c3aed; border: 1px solid rgba(124,58,237,0.2); }
|
||||||
.trip-badge--family { background: rgba(16,185,129,0.85); color: #fff; }
|
.trip-badge--family { background: rgba(16,185,129,0.08); color: #059669; border: 1px solid rgba(16,185,129,0.2); }
|
||||||
|
|
||||||
/* ── Photo grid — fixed height, always consistent ── */
|
/* ── Photo grid — fixed height, always consistent ── */
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
@@ -294,16 +284,12 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
gap: 8px;
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
.city {
|
.days-label {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--text);
|
color: var(--text-sub);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getEntries } from '../stores/entriesStore.svelte.js';
|
import { getEntries } from '../../stores/entriesStore.svelte.js';
|
||||||
import TimelineToolbar from './TimelineToolbar.svelte';
|
|
||||||
import TimelineCard from './TimelineCard.svelte';
|
import TimelineCard from './TimelineCard.svelte';
|
||||||
import JournalDetail from './JournalDetail.svelte';
|
import JournalDetail from '../detail/JournalDetail.svelte';
|
||||||
import EditForm from './EditForm.svelte';
|
import EditForm from '../detail/EditForm.svelte';
|
||||||
import NewEntryForm from './NewEntryForm.svelte';
|
import NewEntryForm from '../detail/NewEntryForm.svelte';
|
||||||
import ShareCard from './ShareCard.svelte';
|
import ShareCard from './ShareCard.svelte';
|
||||||
import SharePreview from './SharePreview.svelte';
|
import SharePreview from './SharePreview.svelte';
|
||||||
|
|
||||||
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
|
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {}, onGoToMap = () => {} } = $props();
|
||||||
let selectedId = $state(/** @type {string|null} */(null));
|
let selectedId = $state(/** @type {string|null} */(null));
|
||||||
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
|
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
|
||||||
let showShare = $state(false);
|
let showShare = $state(false);
|
||||||
@@ -50,7 +49,7 @@
|
|||||||
|
|
||||||
{#if view === 'new'}
|
{#if view === 'new'}
|
||||||
<div class="detail-scroll">
|
<div class="detail-scroll">
|
||||||
<NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} />
|
<NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} onSaved={() => { onGoToMap(); }} />
|
||||||
</div>
|
</div>
|
||||||
{:else if view === 'edit' && selected}
|
{:else if view === 'edit' && selected}
|
||||||
<div class="detail-scroll">
|
<div class="detail-scroll">
|
||||||
@@ -65,8 +64,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="right-panel">
|
<div class="list-view">
|
||||||
<div class="center-col">
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">My Journey</h1>
|
<h1 class="page-title">My Journey</h1>
|
||||||
<button class="new-btn" onclick={() => { view = 'new'; }}>
|
<button class="new-btn" onclick={() => { view = 'new'; }}>
|
||||||
@@ -77,14 +75,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if sortedEntries.length > 0}
|
<div class="two-col">
|
||||||
<div class="share-row">
|
<div class="left-col">
|
||||||
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
|
|
||||||
|
|
||||||
{#if sortedEntries.length === 0}
|
{#if sortedEntries.length === 0}
|
||||||
<p class="empty">No journal entries yet.</p>
|
<p class="empty">No journal entries yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -113,6 +105,13 @@
|
|||||||
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
|
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if sortedEntries.length > 0}
|
||||||
|
<div class="right-col">
|
||||||
|
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -132,31 +131,51 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Right panel ── */
|
/* ── List view wrapper (scrollable) ── */
|
||||||
.right-panel {
|
.list-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 48px 0 80px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.two-col {
|
||||||
|
max-width: 960px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 48px;
|
||||||
|
padding-right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two-column below header ── */
|
||||||
|
.two-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-col {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Centered single column ── */
|
.right-col {
|
||||||
.center-col {
|
width: 260px;
|
||||||
max-width: 680px;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
position: sticky;
|
||||||
margin: 0 auto;
|
top: 0;
|
||||||
padding: 48px 48px 80px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-row {
|
@media (max-width: 900px) {
|
||||||
margin-bottom: 24px;
|
.right-col { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.center-col {
|
.list-view { padding: 32px 24px 60px; }
|
||||||
padding: 32px 24px 60px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Detail view ── */
|
/* ── Detail view ── */
|
||||||
@@ -270,38 +289,4 @@
|
|||||||
}
|
}
|
||||||
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||||
|
|
||||||
.share-nudge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px dashed var(--border-bright);
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
font-family: var(--sans);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.share-nudge:hover {
|
|
||||||
border-color: var(--accent-light);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
}
|
|
||||||
.nudge-left {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
.nudge-right {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-sub);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.share-nudge:hover .nudge-right { color: var(--accent); }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -3,28 +3,19 @@
|
|||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import worldData from 'world-atlas/countries-50m.json';
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { journals } from '../stores/entriesStore.svelte.js';
|
||||||
|
import airplaneImg from '../../assets/airplane-animation.png';
|
||||||
|
|
||||||
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
||||||
|
|
||||||
const HOME_CODE = '203';
|
const HOME_CODE = '203';
|
||||||
|
|
||||||
const MOCK_TRIPS = [
|
const PLANE_SIZE = 26;
|
||||||
{ countryName: 'Japan', countryCode: '392', date: '2024-03-15', city: 'Tokyo' },
|
|
||||||
{ countryName: 'France', countryCode: '191', date: '2024-06-20', city: 'Paris' },
|
|
||||||
{ countryName: 'Spain', countryCode: '724', date: '2024-09-10', city: 'Barcelona' },
|
|
||||||
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' },
|
|
||||||
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' },
|
|
||||||
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' },
|
|
||||||
{ countryName: 'Kenya', countryCode: '404', date: '2021-11-10', city: 'Nairobi' },
|
|
||||||
{ countryName: 'South Africa', countryCode: '710', date: '2026-02-05', city: 'Cape Town' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const HOME_COLOR = '#8b5cf6';
|
const HOME_COLOR = '#8b5cf6';
|
||||||
const VISITED_COLOR = '#22c55e';
|
const VISITED_COLOR = '#22c55e';
|
||||||
const ARC_COLOR = '#666666';
|
const ARC_COLOR = '#666666';
|
||||||
const PLANE_COLOR = '#7c3aed';
|
|
||||||
const PLANE_IMG = '/airplane.png';
|
|
||||||
const PLANE_SIZE = 28;
|
|
||||||
const UNVISITED = '#ffffff';
|
const UNVISITED = '#ffffff';
|
||||||
|
|
||||||
const TERRITORY_PARENT = {
|
const TERRITORY_PARENT = {
|
||||||
@@ -38,9 +29,7 @@
|
|||||||
'850': '840', '876': '250',
|
'850': '840', '876': '250',
|
||||||
};
|
};
|
||||||
|
|
||||||
function effId(d) {
|
function effId(d) { return TERRITORY_PARENT[d.id] || d.id; }
|
||||||
return TERRITORY_PARENT[d.id] || d.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let frameEl;
|
let frameEl;
|
||||||
let svg, gBase, gCountries, gAnim, pathFn, projection;
|
let svg, gBase, gCountries, gAnim, pathFn, projection;
|
||||||
@@ -57,86 +46,32 @@
|
|||||||
|
|
||||||
function formatDateLabel(dateStr) {
|
function formatDateLabel(dateStr) {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeArc(p1, p2) {
|
function computeArc(p1, p2) {
|
||||||
const interp = d3.geoInterpolate(p1, p2);
|
const interp = d3.geoInterpolate(p1, p2);
|
||||||
const steps = 80;
|
|
||||||
const raw = [];
|
const raw = [];
|
||||||
|
for (let i = 0; i <= 80; i++) {
|
||||||
for (let i = 0; i <= steps; i++) {
|
const t = i / 80;
|
||||||
const t = i / steps;
|
const pt = projection(interp(t));
|
||||||
const geo = interp(t);
|
|
||||||
const pt = projection(geo);
|
|
||||||
if (!pt) continue;
|
if (!pt) continue;
|
||||||
raw.push({ t, x: pt[0], y: pt[1] });
|
raw.push({ t, x: pt[0], y: pt[1] });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.length < 2) return [];
|
if (raw.length < 2) return [];
|
||||||
|
const first = raw[0], last = raw[raw.length - 1];
|
||||||
const first = raw[0];
|
const dist = Math.sqrt((last.x-first.x)**2 + (last.y-first.y)**2);
|
||||||
const last = raw[raw.length - 1];
|
|
||||||
const dx = last.x - first.x;
|
|
||||||
const dy = last.y - first.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
const arcH = Math.max(40, Math.min(200, dist * 0.22));
|
const arcH = Math.max(40, Math.min(200, dist * 0.22));
|
||||||
|
|
||||||
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
|
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAngleAtLength(node, len) {
|
|
||||||
const d = 0.5;
|
|
||||||
const total = node.getTotalLength();
|
|
||||||
const p1 = node.getPointAtLength(Math.max(0, len - d));
|
|
||||||
const p2 = node.getPointAtLength(Math.min(total, len + d));
|
|
||||||
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
|
|
||||||
}
|
|
||||||
|
|
||||||
function planeTransform(x, y, angle, flip) {
|
function planeTransform(x, y, angle, flip) {
|
||||||
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
|
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration, flip = false, maskEl = null) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const node = pathEl.node();
|
|
||||||
if (!node) { resolve(); return; }
|
|
||||||
const totalLength = node.getTotalLength();
|
|
||||||
|
|
||||||
if (totalLength === 0) { resolve(); return; }
|
|
||||||
|
|
||||||
d3.timer(elapsed => {
|
|
||||||
if (isCancelled) { resolve(); return true; }
|
|
||||||
|
|
||||||
const t = Math.min(elapsed / duration, 1);
|
|
||||||
const offset = startOffset + (endOffset - startOffset) * t;
|
|
||||||
|
|
||||||
pathEl.attr('stroke-dashoffset', offset);
|
|
||||||
if (maskEl) maskEl.attr('stroke-dashoffset', offset);
|
|
||||||
|
|
||||||
const drawn = totalLength - offset;
|
|
||||||
const clamped = Math.max(0, Math.min(drawn, totalLength));
|
|
||||||
try {
|
|
||||||
const pt = node.getPointAtLength(clamped);
|
|
||||||
const angle = getAngleAtLength(node, clamped);
|
|
||||||
tipEl.attr('transform', planeTransform(pt.x, pt.y, angle, flip)).attr('opacity', 1);
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t >= 1) {
|
|
||||||
resolve();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => { if (isCancelled) { resolve(); return; } setTimeout(resolve, ms); });
|
||||||
if (isCancelled) { resolve(); return; }
|
|
||||||
const id = setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupProjection(width, height) {
|
function setupProjection(width, height) {
|
||||||
@@ -168,106 +103,81 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'globe') {
|
if (mode === 'globe') {
|
||||||
gBase.append('path')
|
gBase.append('path').attr('class', 'sphere').datum({ type: 'Sphere' })
|
||||||
.attr('class', 'sphere')
|
.attr('d', pathFn).attr('fill', '#a4c8e0').attr('stroke', '#8b9bb0').attr('stroke-width', 1.5);
|
||||||
.datum({ type: 'Sphere' })
|
|
||||||
.attr('d', pathFn)
|
|
||||||
.attr('fill', '#a4c8e0')
|
|
||||||
.attr('stroke', '#8b9bb0')
|
|
||||||
.attr('stroke-width', 1.5);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
countryPaths = gCountries.selectAll('path')
|
countryPaths = gCountries.selectAll('path')
|
||||||
.data(countriesData, d => effId(d))
|
.data(countriesData, d => effId(d)).join('path')
|
||||||
.join('path')
|
.attr('d', pathFn).attr('fill', fillFn)
|
||||||
.attr('d', pathFn)
|
|
||||||
.attr('fill', fillFn)
|
|
||||||
.attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4')
|
.attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4')
|
||||||
.attr('stroke-width', mode === 'globe' ? 0.3 : 0.5);
|
.attr('stroke-width', mode === 'globe' ? 0.3 : 0.5);
|
||||||
|
|
||||||
if (mode === 'map') {
|
if (mode === 'map') renderMicrostates();
|
||||||
renderMicrostates();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMicrostates() {
|
function renderMicrostates() {
|
||||||
gBase.selectAll('.micro-state-j').remove();
|
gBase.selectAll('.micro-state-j').remove();
|
||||||
const threshold = 4;
|
|
||||||
const fillFn = d => {
|
const fillFn = d => {
|
||||||
const id = effId(d);
|
const id = effId(d);
|
||||||
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
|
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
|
||||||
if (visitedCodes.has(id)) return VISITED_COLOR;
|
if (visitedCodes.has(id)) return VISITED_COLOR;
|
||||||
return UNVISITED;
|
return UNVISITED;
|
||||||
};
|
};
|
||||||
countryPaths.each(function (d) {
|
countryPaths.each(function(d) {
|
||||||
if (effId(d) !== d.id) return;
|
if (effId(d) !== d.id) return;
|
||||||
const { width, height } = this.getBBox();
|
const { width, height } = this.getBBox();
|
||||||
if (width < threshold && height < threshold) {
|
if (width < 4 && height < 4) {
|
||||||
const [cx, cy] = pathFn.centroid(d);
|
const [cx, cy] = pathFn.centroid(d);
|
||||||
gBase.append('circle')
|
gBase.append('circle').attr('class', 'micro-state-j').datum(d)
|
||||||
.attr('class', 'micro-state-j')
|
.attr('cx', cx).attr('cy', cy).attr('r', 2).attr('fill', fillFn(d))
|
||||||
.datum(d)
|
.attr('stroke', '#94a3b8').attr('stroke-width', 0.5);
|
||||||
.attr('cx', cx)
|
|
||||||
.attr('cy', cy)
|
|
||||||
.attr('r', 2)
|
|
||||||
.attr('fill', fillFn(d))
|
|
||||||
.attr('stroke', '#94a3b8')
|
|
||||||
.attr('stroke-width', 0.5);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function redrawBase() {
|
function redrawBase() {
|
||||||
countryPaths.attr('d', pathFn);
|
countryPaths.attr('d', pathFn);
|
||||||
if (mode === 'globe') {
|
if (mode === 'globe') gBase.select('.sphere').attr('d', pathFn);
|
||||||
gBase.select('.sphere').attr('d', pathFn);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateGlobeTo(lon, lat, duration = 1500) {
|
function rotateGlobeTo(lon, lat, duration = 1500) {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
if (isCancelled) { resolve(); return; }
|
if (isCancelled) { resolve(); return; }
|
||||||
|
|
||||||
const current = projection.rotate();
|
const current = projection.rotate();
|
||||||
const from = [-current[0], -current[1]];
|
const interp = d3.geoInterpolate([-current[0], -current[1]], [lon, lat]);
|
||||||
const to = [lon, lat];
|
|
||||||
|
|
||||||
const interpolate = d3.geoInterpolate(from, to);
|
|
||||||
|
|
||||||
const timer = d3.timer(elapsed => {
|
const timer = d3.timer(elapsed => {
|
||||||
if (isCancelled) { timer.stop(); resolve(); return true; }
|
if (isCancelled) { timer.stop(); resolve(); return true; }
|
||||||
|
|
||||||
const t = Math.min(elapsed / duration, 1);
|
const t = Math.min(elapsed / duration, 1);
|
||||||
const point = interpolate(t);
|
const point = interp(t);
|
||||||
projection.rotate([-point[0], -point[1]]);
|
projection.rotate([-point[0], -point[1]]);
|
||||||
redrawBase();
|
redrawBase();
|
||||||
|
if (t >= 1) { timer.stop(); resolve(); return true; }
|
||||||
if (t >= 1) {
|
});
|
||||||
timer.stop();
|
});
|
||||||
resolve();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
function createArcEl(iconSrc) {
|
||||||
|
const el = gAnim.append('path')
|
||||||
|
.attr('fill', 'none').attr('stroke', ARC_COLOR)
|
||||||
|
.attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
|
||||||
|
.attr('stroke-linecap', 'round').attr('stroke-dasharray', '10, 6');
|
||||||
|
const tip = gAnim.append('image')
|
||||||
|
.attr('href', iconSrc).attr('width', PLANE_SIZE).attr('height', PLANE_SIZE)
|
||||||
|
.attr('x', -PLANE_SIZE / 2).attr('y', -PLANE_SIZE / 2)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid meet').attr('opacity', 0);
|
||||||
|
return { el, tip };
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateIncrementalPath(el, tip, pts, duration, flip = false) {
|
function animateIncrementalPath(el, tip, pts, duration, flip = false) {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
const steps = pts.length - 1;
|
|
||||||
const lineGen = d3.line().curve(d3.curveBasis);
|
const lineGen = d3.line().curve(d3.curveBasis);
|
||||||
|
|
||||||
d3.timer(elapsed => {
|
d3.timer(elapsed => {
|
||||||
if (isCancelled) { resolve(); return true; }
|
if (isCancelled) { resolve(); return true; }
|
||||||
|
|
||||||
const t = Math.min(elapsed / duration, 1);
|
const t = Math.min(elapsed / duration, 1);
|
||||||
const count = Math.max(2, Math.floor(t * steps) + 1);
|
const count = Math.max(2, Math.floor(t * (pts.length - 1)) + 1);
|
||||||
const visible = pts.slice(0, count);
|
const visible = pts.slice(0, count);
|
||||||
|
if (visible.length >= 2) el.attr('d', lineGen(visible));
|
||||||
if (visible.length >= 2) {
|
|
||||||
el.attr('d', lineGen(visible));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible.length > 0) {
|
if (visible.length > 0) {
|
||||||
const last = visible[visible.length - 1];
|
const last = visible[visible.length - 1];
|
||||||
let angle = 0;
|
let angle = 0;
|
||||||
@@ -277,35 +187,19 @@
|
|||||||
}
|
}
|
||||||
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
|
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
|
||||||
}
|
}
|
||||||
|
if (t >= 1) { resolve(); return true; }
|
||||||
if (t >= 1) {
|
|
||||||
resolve();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateReprojectingArc(el, tip, geoPts, lineGen, duration, flip = false) {
|
function animateReprojectingArc(el, tip, geoPts, lineGen, duration, flip = false) {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
const steps = geoPts.length - 1;
|
|
||||||
|
|
||||||
const timer = d3.timer(elapsed => {
|
const timer = d3.timer(elapsed => {
|
||||||
if (isCancelled) { timer.stop(); resolve(); return true; }
|
if (isCancelled) { timer.stop(); resolve(); return true; }
|
||||||
|
|
||||||
const t = Math.min(elapsed / duration, 1);
|
const t = Math.min(elapsed / duration, 1);
|
||||||
const count = Math.max(2, Math.floor(t * steps) + 1);
|
const count = Math.max(2, Math.floor(t * (geoPts.length - 1)) + 1);
|
||||||
const visible = geoPts.slice(0, count);
|
const screenPts = geoPts.slice(0, count).map(p => projection(p)).filter(Boolean);
|
||||||
const screenPts = [];
|
if (screenPts.length >= 2) el.attr('d', lineGen(screenPts));
|
||||||
for (const p of visible) {
|
|
||||||
const pt = projection(p);
|
|
||||||
if (pt) screenPts.push(pt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (screenPts.length >= 2) {
|
|
||||||
el.attr('d', lineGen(screenPts));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (screenPts.length > 0) {
|
if (screenPts.length > 0) {
|
||||||
const last = screenPts[screenPts.length - 1];
|
const last = screenPts[screenPts.length - 1];
|
||||||
let angle = 0;
|
let angle = 0;
|
||||||
@@ -315,323 +209,174 @@
|
|||||||
}
|
}
|
||||||
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
|
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
|
||||||
}
|
}
|
||||||
|
if (t >= 1) { timer.stop(); resolve(); return true; }
|
||||||
if (t >= 1) {
|
|
||||||
timer.stop();
|
|
||||||
resolve();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function animateTrip(destCode, destFeature) {
|
async function animateTrip(destCode, destFeature, transport = 'flight') {
|
||||||
if (!homeFeature || !destFeature) return;
|
if (!homeFeature || !destFeature) return;
|
||||||
|
const iconSrc = airplaneImg;
|
||||||
const homeCentroid = d3.geoCentroid(homeFeature);
|
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||||
const destCentroid = d3.geoCentroid(destFeature);
|
const destCentroid = d3.geoCentroid(destFeature);
|
||||||
|
|
||||||
if (mode === 'map') {
|
if (mode === 'map') {
|
||||||
await animateMapTrip(homeCentroid, destCentroid, destCode);
|
await animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc);
|
||||||
} else {
|
} else {
|
||||||
await animateGlobeTrip(homeCentroid, destCentroid, destCode);
|
await animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function animateMapTrip(homeCentroid, destCentroid, destCode) {
|
async function animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc) {
|
||||||
const pts = computeArc(homeCentroid, destCentroid);
|
const pts = computeArc(homeCentroid, destCentroid);
|
||||||
if (pts.length < 2) return;
|
if (pts.length < 2) return;
|
||||||
|
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
|
||||||
const outFlip = pts[pts.length - 1][0] < pts[0][0];
|
await animateIncrementalPath(outEl, outTip, pts, 2500, pts[pts.length-1][0] < pts[0][0]);
|
||||||
|
|
||||||
const outEl = gAnim.append('path')
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke', ARC_COLOR)
|
|
||||||
.attr('stroke-width', 2.5)
|
|
||||||
.attr('stroke-opacity', 0.8)
|
|
||||||
.attr('stroke-linecap', 'round')
|
|
||||||
.attr('stroke-dasharray', '10, 6');
|
|
||||||
const outTip = gAnim.append('image')
|
|
||||||
.attr('href', PLANE_IMG)
|
|
||||||
.attr('width', PLANE_SIZE)
|
|
||||||
.attr('height', PLANE_SIZE)
|
|
||||||
.attr('x', -PLANE_SIZE / 2)
|
|
||||||
.attr('y', -PLANE_SIZE / 2)
|
|
||||||
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
||||||
.attr('opacity', 0);
|
|
||||||
|
|
||||||
await animateIncrementalPath(outEl, outTip, pts, 2500, outFlip);
|
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
outEl.remove(); outTip.remove();
|
||||||
outEl.remove();
|
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
outTip.remove();
|
|
||||||
|
|
||||||
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
|
||||||
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
|
||||||
visitedCodes.add(destCode);
|
visitedCodes.add(destCode);
|
||||||
gBase.selectAll('.micro-state-j')
|
gBase.selectAll('.micro-state-j').filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
.filter(d => effId(d) === destCode)
|
|
||||||
.transition().duration(500)
|
|
||||||
.attr('fill', VISITED_COLOR);
|
|
||||||
|
|
||||||
await delay(800);
|
await delay(800);
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
const revPts = [...pts].reverse();
|
const revPts = [...pts].reverse();
|
||||||
const retFlip = revPts[revPts.length - 1][0] < revPts[0][0];
|
const { el: retEl, tip: retTip } = createArcEl(iconSrc);
|
||||||
|
await animateIncrementalPath(retEl, retTip, revPts, 2200, revPts[revPts.length-1][0] < revPts[0][0]);
|
||||||
const retEl = gAnim.append('path')
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke', ARC_COLOR)
|
|
||||||
.attr('stroke-width', 2.5)
|
|
||||||
.attr('stroke-opacity', 0.8)
|
|
||||||
.attr('stroke-linecap', 'round')
|
|
||||||
.attr('stroke-dasharray', '10, 6');
|
|
||||||
const retTip = gAnim.append('image')
|
|
||||||
.attr('href', PLANE_IMG)
|
|
||||||
.attr('width', PLANE_SIZE)
|
|
||||||
.attr('height', PLANE_SIZE)
|
|
||||||
.attr('x', -PLANE_SIZE / 2)
|
|
||||||
.attr('y', -PLANE_SIZE / 2)
|
|
||||||
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
||||||
.attr('opacity', 0);
|
|
||||||
|
|
||||||
await animateIncrementalPath(retEl, retTip, revPts, 2200, retFlip);
|
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
retEl.remove(); retTip.remove();
|
||||||
retEl.remove();
|
|
||||||
retTip.remove();
|
|
||||||
|
|
||||||
await delay(300);
|
await delay(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function animateGlobeTrip(homeCentroid, destCentroid, destCode) {
|
async function animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc) {
|
||||||
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
|
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
|
||||||
const steps = 80;
|
const geoPts = Array.from({ length: 81 }, (_, i) => interp(i / 80));
|
||||||
const geoPts = [];
|
const dur = Math.round(1500 + d3.geoDistance(homeCentroid, destCentroid) * 2500);
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
geoPts.push(interp(i / steps));
|
|
||||||
}
|
|
||||||
|
|
||||||
const dist = d3.geoDistance(homeCentroid, destCentroid);
|
|
||||||
const dur = Math.round(1500 + dist * 2500);
|
|
||||||
|
|
||||||
const lineGen = d3.line().curve(d3.curveBasis);
|
const lineGen = d3.line().curve(d3.curveBasis);
|
||||||
|
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
|
||||||
const outEl = gAnim.append('path')
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke', ARC_COLOR)
|
|
||||||
.attr('stroke-width', 2.5)
|
|
||||||
.attr('stroke-opacity', 0.8)
|
|
||||||
.attr('stroke-linecap', 'round')
|
|
||||||
.attr('stroke-dasharray', '10, 6');
|
|
||||||
const outTip = gAnim.append('image')
|
|
||||||
.attr('href', PLANE_IMG)
|
|
||||||
.attr('width', PLANE_SIZE)
|
|
||||||
.attr('height', PLANE_SIZE)
|
|
||||||
.attr('x', -PLANE_SIZE / 2)
|
|
||||||
.attr('y', -PLANE_SIZE / 2)
|
|
||||||
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
||||||
.attr('opacity', 0);
|
|
||||||
|
|
||||||
const outGcs = geoPts.map(p => projection(p)).filter(Boolean);
|
const outGcs = geoPts.map(p => projection(p)).filter(Boolean);
|
||||||
const outFlipGlobe = outGcs.length >= 2 && outGcs[outGcs.length - 1][0] < outGcs[0][0];
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
|
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
|
||||||
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outFlipGlobe)
|
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outGcs.length >= 2 && outGcs[outGcs.length-1][0] < outGcs[0][0]),
|
||||||
]);
|
]);
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
outEl.remove(); outTip.remove();
|
||||||
outEl.remove();
|
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
outTip.remove();
|
|
||||||
|
|
||||||
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
|
||||||
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
|
||||||
visitedCodes.add(destCode);
|
visitedCodes.add(destCode);
|
||||||
|
|
||||||
await delay(600);
|
await delay(600);
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
const revGeoPts = [...geoPts].reverse();
|
const revGeoPts = [...geoPts].reverse();
|
||||||
|
const { el: retEl, tip: retTip } = createArcEl(iconSrc);
|
||||||
const retEl = gAnim.append('path')
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke', ARC_COLOR)
|
|
||||||
.attr('stroke-width', 2.5)
|
|
||||||
.attr('stroke-opacity', 0.8)
|
|
||||||
.attr('stroke-linecap', 'round')
|
|
||||||
.attr('stroke-dasharray', '10, 6');
|
|
||||||
const retTip = gAnim.append('image')
|
|
||||||
.attr('href', PLANE_IMG)
|
|
||||||
.attr('width', PLANE_SIZE)
|
|
||||||
.attr('height', PLANE_SIZE)
|
|
||||||
.attr('x', -PLANE_SIZE / 2)
|
|
||||||
.attr('y', -PLANE_SIZE / 2)
|
|
||||||
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
||||||
.attr('opacity', 0);
|
|
||||||
|
|
||||||
const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean);
|
const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean);
|
||||||
const retFlipGlobe = retGcs.length >= 2 && retGcs[retGcs.length - 1][0] < retGcs[0][0];
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
|
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
|
||||||
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retFlipGlobe)
|
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retGcs.length >= 2 && retGcs[retGcs.length-1][0] < retGcs[0][0]),
|
||||||
]);
|
]);
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
retEl.remove(); retTip.remove();
|
||||||
retEl.remove();
|
|
||||||
retTip.remove();
|
|
||||||
|
|
||||||
await delay(300);
|
await delay(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startJourney() {
|
async function startJourney() {
|
||||||
const myId = ++animId;
|
const myId = ++animId;
|
||||||
isPlaying = true;
|
isPlaying = true; isFinished = false; isCancelled = false; visitedCodes = new Set();
|
||||||
isFinished = false;
|
|
||||||
isCancelled = false;
|
|
||||||
visitedCodes = new Set();
|
|
||||||
|
|
||||||
const width = frameEl.clientWidth;
|
|
||||||
const height = frameEl.clientHeight;
|
|
||||||
|
|
||||||
|
const width = frameEl.clientWidth, height = frameEl.clientHeight;
|
||||||
svg.selectAll('*').remove();
|
svg.selectAll('*').remove();
|
||||||
gBase = svg.append('g');
|
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
|
||||||
gCountries = svg.append('g');
|
|
||||||
gAnim = svg.append('g');
|
|
||||||
|
|
||||||
setupProjection(width, height);
|
setupProjection(width, height);
|
||||||
|
|
||||||
if (mode === 'globe' && homeFeature) {
|
if (mode === 'globe' && homeFeature) {
|
||||||
const c = d3.geoCentroid(homeFeature);
|
const c = d3.geoCentroid(homeFeature);
|
||||||
projection.rotate([-c[0], -c[1]]);
|
projection.rotate([-c[0], -c[1]]);
|
||||||
pathFn = d3.geoPath().projection(projection);
|
pathFn = d3.geoPath().projection(projection);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMap();
|
renderMap();
|
||||||
|
|
||||||
const trips = MOCK_TRIPS;
|
const nameToId = Object.fromEntries(Object.entries(featuresById).filter(([,f]) => f.properties?.name).map(([id, f]) => [f.properties.name, id]));
|
||||||
const total = trips.length;
|
const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
const trips = entries.length > 0
|
||||||
|
? entries.map(e => ({
|
||||||
|
countryName: e.location.country,
|
||||||
|
countryCode: nameToId[e.location.country] ?? null,
|
||||||
|
city: e.location.cities?.[0] ?? e.location.country,
|
||||||
|
transport: e.transport ?? 'flight',
|
||||||
|
date: e.date,
|
||||||
|
})).filter(t => t.countryCode)
|
||||||
|
: [
|
||||||
|
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' },
|
||||||
|
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' },
|
||||||
|
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' },
|
||||||
|
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' },
|
||||||
|
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' },
|
||||||
|
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' },
|
||||||
|
];
|
||||||
|
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < trips.length; i++) {
|
||||||
if (isCancelled || myId !== animId) break;
|
if (isCancelled || myId !== animId) break;
|
||||||
|
|
||||||
const trip = trips[i];
|
const trip = trips[i];
|
||||||
currentDateLabel = formatDateLabel(trip.date);
|
if (trip.date) currentDateLabel = formatDateLabel(trip.date);
|
||||||
const destFeature = featuresById[trip.countryCode];
|
const destFeature = featuresById[trip.countryCode];
|
||||||
if (!destFeature) continue;
|
if (!destFeature) continue;
|
||||||
|
if (onprogress) onprogress({ index: i + 1, total: trips.length, label: `${trip.city}, ${trip.countryName}` });
|
||||||
const label = `${trip.city}, ${trip.countryName}`;
|
await animateTrip(trip.countryCode, destFeature, trip.transport);
|
||||||
if (onprogress) onprogress({ index: i + 1, total, label });
|
|
||||||
|
|
||||||
await animateTrip(trip.countryCode, destFeature);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCancelled && myId === animId) {
|
if (!isCancelled && myId === animId) {
|
||||||
isFinished = true;
|
isFinished = true; isPlaying = false;
|
||||||
isPlaying = false;
|
|
||||||
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
||||||
} else if (myId === animId) {
|
setTimeout(() => close(), 2500);
|
||||||
isPlaying = false;
|
} else if (myId === animId) { isPlaying = false; }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopJourney() {
|
|
||||||
isCancelled = true;
|
|
||||||
isPlaying = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replay() {
|
|
||||||
stopJourney();
|
|
||||||
setTimeout(() => startJourney(), 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopJourney() { isCancelled = true; isPlaying = false; }
|
||||||
|
function replay() { stopJourney(); setTimeout(() => startJourney(), 100); }
|
||||||
function switchMode(target) {
|
function switchMode(target) {
|
||||||
if (target === mode) return;
|
if (target === mode) return;
|
||||||
onmodechange?.(target);
|
onmodechange?.(target);
|
||||||
stopJourney();
|
stopJourney();
|
||||||
setTimeout(() => startJourney(), 100);
|
setTimeout(() => startJourney(), 100);
|
||||||
}
|
}
|
||||||
|
function close() { stopJourney(); onclose?.(); }
|
||||||
function close() {
|
|
||||||
stopJourney();
|
|
||||||
onclose?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const width = frameEl.clientWidth;
|
const width = frameEl.clientWidth, height = frameEl.clientHeight;
|
||||||
const height = frameEl.clientHeight;
|
|
||||||
|
|
||||||
setupProjection(width, height);
|
setupProjection(width, height);
|
||||||
|
|
||||||
countriesData = feature(worldData, worldData.objects.countries)
|
countriesData = feature(worldData, worldData.objects.countries)
|
||||||
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
||||||
|
countriesData.forEach(f => { if (!f.id) f.id = 'XK'; });
|
||||||
countriesData.forEach(f => {
|
for (const f of countriesData) featuresById[effId(f)] = f;
|
||||||
if (!f.id) f.id = 'XK';
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const f of countriesData) {
|
|
||||||
featuresById[effId(f)] = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
homeFeature = featuresById[HOME_CODE];
|
homeFeature = featuresById[HOME_CODE];
|
||||||
|
|
||||||
svg = d3.select(frameEl)
|
svg = d3.select(frameEl).append('svg').attr('width', width).attr('height', height).style('cursor', 'default');
|
||||||
.append('svg')
|
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
|
||||||
.attr('width', width)
|
|
||||||
.attr('height', height)
|
|
||||||
.style('cursor', 'default');
|
|
||||||
|
|
||||||
gBase = svg.append('g');
|
|
||||||
gCountries = svg.append('g');
|
|
||||||
gAnim = svg.append('g');
|
|
||||||
|
|
||||||
renderMap();
|
renderMap();
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const { width, height } = entry.contentRect;
|
const { width, height } = entry.contentRect;
|
||||||
svg.attr('width', width).attr('height', height);
|
svg.attr('width', width).attr('height', height);
|
||||||
const prevProj = projection;
|
const prevRotate = mode === 'globe' ? projection.rotate() : null;
|
||||||
if (mode === 'map') {
|
if (mode === 'map') {
|
||||||
projection = d3.geoMercator();
|
projection = d3.geoMercator();
|
||||||
projection.fitSize([width, height], { type: 'Sphere' });
|
projection.fitSize([width, height], { type: 'Sphere' });
|
||||||
const s = projection.scale() * 1.5;
|
projection.scale(projection.scale() * 1.5).translate([width / 2, height * 0.70]);
|
||||||
projection.scale(s).translate([width / 2, height * 0.70]);
|
|
||||||
} else {
|
} else {
|
||||||
const size = Math.min(width, height) * 0.92;
|
const size = Math.min(width, height) * 0.92;
|
||||||
projection = d3.geoOrthographic()
|
projection = d3.geoOrthographic().rotate(prevRotate).fitSize([size, size], { type: 'Sphere' }).translate([width / 2, height / 2]);
|
||||||
.rotate(prevProj.rotate())
|
|
||||||
.fitSize([size, size], { type: 'Sphere' })
|
|
||||||
.translate([width / 2, height / 2]);
|
|
||||||
}
|
}
|
||||||
pathFn = d3.geoPath().projection(projection);
|
pathFn = d3.geoPath().projection(projection);
|
||||||
redrawBase();
|
redrawBase();
|
||||||
if (mode === 'map') renderMicrostates();
|
if (mode === 'map') renderMicrostates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(frameEl);
|
observer.observe(frameEl);
|
||||||
|
|
||||||
startJourney();
|
startJourney();
|
||||||
|
return () => { stopJourney(); observer.disconnect(); if (svg) svg.remove(); };
|
||||||
return () => {
|
|
||||||
stopJourney();
|
|
||||||
observer.disconnect();
|
|
||||||
if (svg) svg.remove();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
|
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
|
||||||
<div class="top-label">
|
<div class="top-label">
|
||||||
{#if isFinished}
|
{#if isFinished}Journey complete!{:else if currentDateLabel}{currentDateLabel}{/if}
|
||||||
Journey complete!
|
|
||||||
{:else if currentDateLabel}
|
|
||||||
{currentDateLabel}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="control-bar">
|
<div class="control-bar">
|
||||||
<button class="control-btn" onclick={replay}>
|
<button class="control-btn" onclick={replay}>
|
||||||
@@ -647,67 +392,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.journey-frame {
|
.journey-frame { width: 100%; height: 100%; overflow: hidden; position: relative; background: #a4c8e0; }
|
||||||
width: 100%;
|
.journey-frame.globe-mode { background: #ffffff; }
|
||||||
height: 100%;
|
.journey-frame :global(svg) { display: block; }
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
background: #a4c8e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-frame.globe-mode {
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-frame :global(svg) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-label {
|
.top-label {
|
||||||
position: absolute;
|
position: absolute; top: 16px; left: 16px; z-index: 10;
|
||||||
top: 16px;
|
background: rgba(0,0,0,0.65); color: #fff;
|
||||||
left: 16px;
|
font-family: var(--heading, sans-serif); font-size: 16px; font-weight: 600;
|
||||||
z-index: 10;
|
padding: 10px 24px; border-radius: 24px; white-space: nowrap;
|
||||||
background: rgba(0,0,0,0.65);
|
letter-spacing: 0.04em; min-width: 200px; text-align: center;
|
||||||
color: #fff;
|
|
||||||
font-family: var(--heading, sans-serif);
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 24px;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
min-width: 200px;
|
|
||||||
text-align: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-bar {
|
.control-bar {
|
||||||
position: absolute;
|
position: absolute; bottom: 24px; right: 24px; z-index: 10;
|
||||||
bottom: 24px;
|
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
|
||||||
right: 24px;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
padding: 10px 24px;
|
padding: 10px 24px; border: none; border-radius: 24px;
|
||||||
border: none;
|
background: #8b5cf6; color: #fff; font-size: 14px; font-weight: 600;
|
||||||
border-radius: 24px;
|
cursor: pointer; transition: background 0.15s ease; white-space: nowrap; font-family: inherit;
|
||||||
background: #8b5cf6;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn:hover {
|
|
||||||
background: #7c3aed;
|
|
||||||
}
|
}
|
||||||
|
.control-btn:hover { background: #7c3aed; }
|
||||||
|
.control-btn:active { transform: scale(0.96); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import worldData from 'world-atlas/countries-50m.json';
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
import { getSelected, toggle, setTotalCount, getHomeCountryCode } from '../layout/selection.svelte.js';
|
import { getSelected, setTotalCount, getFlashing } from '../layout/selection.svelte.js';
|
||||||
|
import { getUserProfile } from '../auth/userStore.svelte.js';
|
||||||
|
import { nameToId } from '../shared/countries.js';
|
||||||
|
import homeIconUrl from '../../assets/home.png';
|
||||||
|
import crayonCursorUrl from '../../assets/logo-cursor.png';
|
||||||
|
|
||||||
let { onCountryClick = (_name) => {} } = $props();
|
let { onCountryClick = (_name) => {} } = $props();
|
||||||
|
|
||||||
@@ -60,21 +64,24 @@
|
|||||||
|
|
||||||
function countryColor(d, sel, homeCode) {
|
function countryColor(d, sel, homeCode) {
|
||||||
const id = effId(d);
|
const id = effId(d);
|
||||||
if (!sel.has(id)) return UNVISITED_COLOR;
|
|
||||||
if (id === homeCode) return HOME_COLOR;
|
if (id === homeCode) return HOME_COLOR;
|
||||||
|
if (!sel.has(id)) return UNVISITED_COLOR;
|
||||||
return VISITED_COLOR;
|
return VISITED_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countryHoverColor(d, sel, homeCode) {
|
function countryHoverColor(d, sel, homeCode) {
|
||||||
const id = effId(d);
|
const id = effId(d);
|
||||||
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
|
|
||||||
if (id === homeCode) return HOME_COLOR_HOVER;
|
if (id === homeCode) return HOME_COLOR_HOVER;
|
||||||
|
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
|
||||||
return VISITED_COLOR_HOVER;
|
return VISITED_COLOR_HOVER;
|
||||||
}
|
}
|
||||||
|
|
||||||
let frameEl;
|
let frameEl;
|
||||||
let _paths = null;
|
let _paths = $state(null);
|
||||||
let _g = null;
|
let _g = null;
|
||||||
|
let _pathFn = null;
|
||||||
|
let _countries = null;
|
||||||
|
|
||||||
|
|
||||||
function fitProjection(proj, w, h) {
|
function fitProjection(proj, w, h) {
|
||||||
proj.fitSize([w, h], { type: 'Sphere' });
|
proj.fitSize([w, h], { type: 'Sphere' });
|
||||||
@@ -82,9 +89,14 @@
|
|||||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHomeCode() {
|
||||||
|
const name = getUserProfile()?.homeCountry;
|
||||||
|
return name ? (nameToId[name] ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function updateAllFills() {
|
function updateAllFills() {
|
||||||
const sel = getSelected();
|
const sel = getSelected();
|
||||||
const hc = getHomeCountryCode();
|
const hc = getHomeCode();
|
||||||
if (!_paths || !_g) return;
|
if (!_paths || !_g) return;
|
||||||
_paths.attr('fill', d => countryColor(d, sel, hc));
|
_paths.attr('fill', d => countryColor(d, sel, hc));
|
||||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
||||||
@@ -92,6 +104,45 @@
|
|||||||
|
|
||||||
$effect(updateAllFills);
|
$effect(updateAllFills);
|
||||||
|
|
||||||
|
function placeHomeMarker() {
|
||||||
|
if (!_g || !_pathFn || !_countries) return;
|
||||||
|
_g.selectAll('.home-marker').remove();
|
||||||
|
const name = getUserProfile()?.homeCountry;
|
||||||
|
if (!name) return;
|
||||||
|
const found = _countries.find(f => f.properties.name === name);
|
||||||
|
if (!found) return;
|
||||||
|
const [cx, cy] = _pathFn.centroid(found);
|
||||||
|
if (isNaN(cx) || isNaN(cy)) return;
|
||||||
|
const SIZE = 14;
|
||||||
|
_g.append('image')
|
||||||
|
.attr('class', 'home-marker')
|
||||||
|
.attr('href', homeIconUrl)
|
||||||
|
.attr('x', cx - SIZE / 2)
|
||||||
|
.attr('y', cy - SIZE / 2)
|
||||||
|
.attr('width', SIZE)
|
||||||
|
.attr('height', SIZE)
|
||||||
|
.style('pointer-events', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(placeHomeMarker);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const flashSet = getFlashing();
|
||||||
|
const paths = _paths; // reactive read so effect re-runs when _paths is set
|
||||||
|
if (!paths || flashSet.size === 0) return;
|
||||||
|
paths
|
||||||
|
.filter(d => flashSet.has(effId(d)))
|
||||||
|
.each(function() {
|
||||||
|
d3.select(this).interrupt()
|
||||||
|
.transition().duration(200).attr('fill', '#facc15')
|
||||||
|
.transition().duration(200).attr('fill', '#fb923c')
|
||||||
|
.transition().duration(200).attr('fill', '#facc15')
|
||||||
|
.transition().duration(200).attr('fill', '#fb923c')
|
||||||
|
.transition().duration(200).attr('fill', '#facc15')
|
||||||
|
.transition().duration(400).attr('fill', VISITED_COLOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const width = frameEl.clientWidth;
|
const width = frameEl.clientWidth;
|
||||||
const height = frameEl.clientHeight;
|
const height = frameEl.clientHeight;
|
||||||
@@ -108,6 +159,9 @@
|
|||||||
if (!f.id) f.id = 'XK';
|
if (!f.id) f.id = 'XK';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_pathFn = path;
|
||||||
|
_countries = countries;
|
||||||
|
|
||||||
const sovereignIds = new Set(countries.map(f => effId(f)));
|
const sovereignIds = new Set(countries.map(f => effId(f)));
|
||||||
setTotalCount(sovereignIds.size);
|
setTotalCount(sovereignIds.size);
|
||||||
|
|
||||||
@@ -123,34 +177,23 @@
|
|||||||
.attr('class', 'tooltip')
|
.attr('class', 'tooltip')
|
||||||
.style('display', 'none');
|
.style('display', 'none');
|
||||||
|
|
||||||
function updateFill(sel) {
|
|
||||||
const s = getSelected();
|
|
||||||
const hc = getHomeCountryCode();
|
|
||||||
sel.attr('fill', d => countryColor(d, s, hc));
|
|
||||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, s, hc));
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachEvents(sel) {
|
function attachEvents(sel) {
|
||||||
sel
|
sel
|
||||||
.on('click', (event, d) => {
|
.on('click', (event, d) => {
|
||||||
toggle(effId(d));
|
|
||||||
updateFill(d3.select(event.currentTarget));
|
|
||||||
onCountryClick(d.properties.name);
|
onCountryClick(d.properties.name);
|
||||||
})
|
})
|
||||||
.on('mouseenter', (event, d) => {
|
.on('mouseenter', (event, d) => {
|
||||||
const s = getSelected();
|
const s = getSelected();
|
||||||
const hc = getHomeCountryCode();
|
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, getHomeCode()));
|
||||||
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
|
|
||||||
tooltip.style('display', 'block').text(d.properties.name);
|
tooltip.style('display', 'block').text(d.properties.name);
|
||||||
})
|
})
|
||||||
.on('mousemove', (event) => {
|
.on('mousemove', (event) => {
|
||||||
const [x, y] = d3.pointer(event, frameEl);
|
const [x, y] = d3.pointer(event, frameEl);
|
||||||
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
|
tooltip.style('left', (x + 22) + 'px').style('top', (y - 28) + 'px');
|
||||||
})
|
})
|
||||||
.on('mouseleave', (event, d) => {
|
.on('mouseleave', (event, d) => {
|
||||||
const s = getSelected();
|
const s = getSelected();
|
||||||
const hc = getHomeCountryCode();
|
d3.select(event.currentTarget).attr('fill', countryColor(d, s, getHomeCode()));
|
||||||
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
|
|
||||||
tooltip.style('display', 'none');
|
tooltip.style('display', 'none');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,14 +215,13 @@
|
|||||||
const { width, height } = this.getBBox();
|
const { width, height } = this.getBBox();
|
||||||
if (width < threshold && height < threshold) {
|
if (width < threshold && height < threshold) {
|
||||||
const [cx, cy] = path.centroid(d);
|
const [cx, cy] = path.centroid(d);
|
||||||
const hc = getHomeCountryCode();
|
|
||||||
const c = _g.append('circle')
|
const c = _g.append('circle')
|
||||||
.attr('class', 'micro-state')
|
.attr('class', 'micro-state')
|
||||||
.datum(d)
|
.datum(d)
|
||||||
.attr('cx', cx)
|
.attr('cx', cx)
|
||||||
.attr('cy', cy)
|
.attr('cy', cy)
|
||||||
.attr('r', 2)
|
.attr('r', 2)
|
||||||
.attr('fill', countryColor(d, getSelected(), hc))
|
.attr('fill', countryColor(d, getSelected(), getHomeCode()))
|
||||||
.attr('stroke', '#94a3b8')
|
.attr('stroke', '#94a3b8')
|
||||||
.attr('stroke-width', 0.5);
|
.attr('stroke-width', 0.5);
|
||||||
attachEvents(c);
|
attachEvents(c);
|
||||||
@@ -188,6 +230,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderMicrostates();
|
renderMicrostates();
|
||||||
|
placeHomeMarker();
|
||||||
|
|
||||||
const zoom = d3.zoom()
|
const zoom = d3.zoom()
|
||||||
.scaleExtent([1, 32])
|
.scaleExtent([1, 32])
|
||||||
@@ -211,8 +254,9 @@
|
|||||||
fitProjection(projection, width, height);
|
fitProjection(projection, width, height);
|
||||||
const countryPaths = _g.selectAll('path');
|
const countryPaths = _g.selectAll('path');
|
||||||
countryPaths.attr('d', path);
|
countryPaths.attr('d', path);
|
||||||
updateFill(countryPaths);
|
updateAllFills();
|
||||||
renderMicrostates();
|
renderMicrostates();
|
||||||
|
placeHomeMarker();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,7 +269,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={frameEl} class="map-frame"></div>
|
<div bind:this={frameEl} class="map-frame" style="cursor: url({crayonCursorUrl}) 4 28, crosshair;"></div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.map-frame {
|
.map-frame {
|
||||||
@@ -238,15 +282,15 @@
|
|||||||
|
|
||||||
.map-frame :global(svg) {
|
.map-frame :global(svg) {
|
||||||
display: block;
|
display: block;
|
||||||
cursor: grab;
|
cursor: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-frame :global(svg:active) {
|
.map-frame :global(svg:active) {
|
||||||
cursor: grabbing;
|
cursor: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-frame :global(svg path) {
|
.map-frame :global(svg path) {
|
||||||
cursor: pointer;
|
cursor: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-frame :global(.tooltip) {
|
.map-frame :global(.tooltip) {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||