Compare commits
18 Commits
cbdf489f1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 522e41cb79 | |||
| caaae887f9 | |||
| 3ae1b25b0c | |||
| e6bc251386 | |||
| 4e1981e1f6 | |||
| 59b89fb276 | |||
| 5231b0ead7 | |||
| 6fc0172ec3 | |||
| c27200d9cd | |||
| 8fbe40f178 | |||
| 8b52437655 | |||
| 5e36f6e140 | |||
| 2f49c35128 | |||
| 3e43d09723 | |||
| 0f1e43d82d | |||
| 944b73d215 | |||
| f22b0f1b28 | |||
| 5e943df6ef |
17
.firebase/hosting.ZGlzdA.cache
Normal file
17
.firebase/hosting.ZGlzdA.cache
Normal file
@@ -0,0 +1,17 @@
|
||||
logo.png,1781605272907,e1d8cae38fa1d4a97f6847ccdc69dae37d8b481e6862625b6d8a6565137a622b
|
||||
index.html,1781628774437,ba46c34c4641f9cba330944cbd3f9b21ee4cb43addcf58dc1876428f5aef2c0c
|
||||
assets/index-DvsKFHEX.css,1781628774437,5c4f357ff59c59917ce7c70ee28b29bc28c9d7eec3f6afd12e4c2cc8120537e1
|
||||
assets/walk-2q6BKbeA.png,1781628774437,19a9f5d599221a0077761a7777285ca17dbc2775a9c4096304098602f2067b87
|
||||
assets/airplane-DW4h3HkK.png,1781628774437,8d70ae12e46f5abdb4e1ccdf59af4bcab1805229d02f1260321f1f61d6375af1
|
||||
assets/ship-xaUNk1p4.png,1781628774438,58b126e20c22710d5af92a45b6ec8af6e0b3dea30a743f9e8b91e849ed66cd91
|
||||
assets/car-C6BGShf6.png,1781628774437,ac10c46e0a8245a9e52b8c3cebe110112f57764c9bda1fe93d4fbc077e79a1b2
|
||||
assets/bus-Dy8WA7au.png,1781628774437,8285587abd857cce3b7083e799a8d2a95b019cfc40546e914cc2b8348c448c10
|
||||
assets/default-3-BcrUpMyO.jpeg,1781628774448,f10d24e80f9f94ddf40d1103ea18f96214e89c18102c75be6b816c8ee0a2b4b9
|
||||
assets/logo-signin-BAMDzBb4.png,1781628774437,7e5d35cac2cea449b27ef72e386e66e1b9c8cbb9261a26b85fda4cb2d89ac226
|
||||
assets/train-DAjH23UQ.png,1781628774437,9b3a2b3b0376f74a47daf69b2f202d757fd8425edede28a8263d2ef5994ed5f2
|
||||
assets/default-2-CoZoomA5.jpeg,1781628774448,fbdb512018aba7f68167796632defe75019af5432ed0bfc48d0e24851c47261f
|
||||
assets/profile-lvFMnGZN.png,1781628774437,0892c6fcffa6be93a86ddedc09b6c26ad725fd1dd66479b4382dbfddcb6d392f
|
||||
assets/default-1-CZLThRLv.jpeg,1781628774448,72f1614c5862c5fa1b79db6130985b681cd971e4c415758990dc1fd3c9eee1a4
|
||||
assets/home-D9_bbHBx.png,1781628774423,201c0fa3a0d38a8ce25c5494d0bd1f2d5d7e0f03fb07cd9c34b3e7593858899f
|
||||
DB-diagram.PNG,1781628177434,978dcc3d9b7e059510c59d5c8a836018dfdb4b6cf59a9e362a89a5a6f9cb6f26
|
||||
assets/index-gfLawFjf.js,1781628774466,b5e58d7bfb36663198ac3fbbece0600acef7c8b8822ab9c1132cfc0a68ce214a
|
||||
135
README.md
135
README.md
@@ -1,23 +1,29 @@
|
||||
# Map Journal
|
||||
|
||||
**Author:** _[Your Name]_
|
||||
**Student ID:** _[Your ID]_
|
||||
**Email:** _[Your Email]_
|
||||
**Authors:** Tomas Horsky, Haeri Kim
|
||||
|
||||
**Student IDs:** 20256426, 20254236
|
||||
|
||||
**Emails:** tomashorsky@kaist.ac.kr , kimhaeri@kaist.ac.kr
|
||||
|
||||
**Live website:** <https://map-jurnal.web.app/>
|
||||
|
||||
**Repository:** <https://git.prototyping.id/20256426/Map-Jurnal.git>
|
||||
**Video Demo:** _[YouTube URL]_
|
||||
|
||||
**Video Demo:** https://youtu.be/j8-Ue4wanRU
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
Journi is a web application for journaling and visualizing travel experiences. Users log trips by country, pinning cities, dates, transport modes, and photos. Then user can explore their journey on an interactive world map, timeline view or can play short animations overviewing his trips.
|
||||
|
||||
### How It Works
|
||||
|
||||
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:
|
||||
3. **Add journal entries**:
|
||||
- **Step 0** — Click on country in map you visited or click add trip in timeline.
|
||||
- **Step 1** — Choose country, cities, arrival date, days stayed, trip type (solo/friends/family), and transport (flight/train/bus/car/ship/walk).
|
||||
- **Step 2** — Upload photos (stored in Firebase Storage).
|
||||
- **Step 3** — Answer three random reflective questions about the trip (e.g., *"What was the most unexpected thing that happened?"*).
|
||||
@@ -28,6 +34,13 @@ Map Journal is a single-page web application for documenting and visualizing tra
|
||||
|
||||
---
|
||||
|
||||
## Backend — Firebase
|
||||
|
||||

|
||||
Journi uses Firebase as its backend. Users sign in with Google through Firebase Authentication, and their data is linked to their user ID. Firestore stores user profiles in `users/{uid}` and journal entries in `users/{uid}/entries/{id}`. Firebase Storage stores uploaded trip photos. Firebase Security Rules protect the data, so authenticated users can only read and write their own profiles, entries, and photos.
|
||||
|
||||
---
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
@@ -81,92 +94,7 @@ src/
|
||||
└── 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
|
||||
|
||||
@@ -182,25 +110,24 @@ npm install
|
||||
# VITE_FIREBASE_MESSAGING_SENDER_ID=...
|
||||
# VITE_FIREBASE_APP_ID=...
|
||||
|
||||
# Dev server
|
||||
# Run server
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
- **Globe animation** — The D3 globe rotation animation during journey replay can be a little bit laggy.
|
||||
- **Switching between animations** - after switching from map to globe animation, the old animation might be still visible for a moment. Restatarting the animation fixes the issue.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **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.
|
||||
- **Opencode & Claude Code (Sonnet 4.6) ** - researching, writing code, debuging... (https://opencode.ai/)
|
||||
- **Gemini Nanobanana ** - generating logo and illustrations
|
||||
- **D3.js docs** — help with using D3.js library (https://d3js.org/)
|
||||
- **D3.js world tour example** — inspiration for adding animation (https://observablehq.com/@d3/world-tour)
|
||||
|
||||
---
|
||||
|
||||
_This project was developed as part of a software prototyping course._
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
"hosting": {
|
||||
"public": "dist",
|
||||
"ignore": ["firebase.json", ".firebaserc"],
|
||||
"rewrites": [
|
||||
{ "source": "**", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "map-journal",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "map-journal",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0",
|
||||
"firebase": "^12.14.0",
|
||||
|
||||
BIN
public/DB-diagram.PNG
Normal file
BIN
public/DB-diagram.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -11,15 +11,24 @@
|
||||
*/
|
||||
let { entry = null, initialCountry = '', onBack } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let isNew = !entry;
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let cities = $state([...(entry?.location.cities ?? [])]);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let country = $state(entry?.location.country ?? initialCountry);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let days = $state(String(entry?.days ?? ''));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tripType = $state(entry?.tripType ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let photos = $state([...(entry?.photos ?? [])]);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let memo = $state(entry?.memo ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let transport = $state(entry?.transport ?? '');
|
||||
|
||||
let step = $state(1);
|
||||
|
||||
@@ -84,9 +84,10 @@
|
||||
<div class="photo-grid">
|
||||
{#each entry.photos as photo, i}
|
||||
<div class="photo-cell" class:cell-wide={i === 0 && entry.photos.length > 1}>
|
||||
<img src={photo} alt=""
|
||||
onclick={() => lightboxSrc = photo}
|
||||
onerror={(e) => e.currentTarget.parentElement.classList.add('cell-broken')} />
|
||||
<button type="button" class="photo-btn" onclick={() => lightboxSrc = photo}>
|
||||
<img src={photo} alt=""
|
||||
onerror={(e) => { e.currentTarget.closest('.photo-cell')?.classList.add('cell-broken'); }} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -254,21 +255,24 @@
|
||||
grid-column: 1 / -1;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.photo-cell img {
|
||||
.photo-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.photo-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.photo-cell:hover img { transform: scale(1.03); }
|
||||
.photo-cell.cell-broken {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-sub);
|
||||
font-size: 12px;
|
||||
}
|
||||
.photo-cell:hover .photo-btn img { transform: scale(1.03); }
|
||||
|
||||
|
||||
.no-photos {
|
||||
display: flex;
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
</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>
|
||||
<span class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></span>
|
||||
<div class="toggle-row">
|
||||
{#each ['solo','friends','family'] as t}
|
||||
<label class="toggle-opt" class:active={tripType === t}>
|
||||
@@ -217,7 +217,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
|
||||
<span class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></span>
|
||||
<div class="transport-grid">
|
||||
{#each transportOptions as opt}
|
||||
<label class="toggle-opt" class:active={transport === opt.value}>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}>
|
||||
<button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading} aria-label="Add photo">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
@@ -101,23 +101,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.add-btn:hover { background: rgba(124,58,237,0.12); }
|
||||
|
||||
.empty-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</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>
|
||||
<span class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></span>
|
||||
<div class="toggle-row">
|
||||
{#each ['solo','friends','family'] as t}
|
||||
<label class="toggle-opt" class:active={tripType === t}>
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
|
||||
<span class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></span>
|
||||
<div class="transport-grid">
|
||||
{#each transportOptions as opt}
|
||||
<label class="toggle-opt transport-opt" class:active={transport === opt.value}>
|
||||
|
||||
@@ -216,12 +216,6 @@
|
||||
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.cities.join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.flightHrs > 0}
|
||||
<div class="fact">
|
||||
<span class="fact-icon">✈️</span>
|
||||
<span class="fact-text">~<strong>{stats.flightHrs} hrs</strong> crossing skies</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.favCountry && stats.favCountry[1] > 1}
|
||||
<div class="fact">
|
||||
<span class="fact-icon">❤️</span>
|
||||
@@ -407,42 +401,6 @@
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Stat grid */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.stat-box {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-desc {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
color: rgba(255,255,255,0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Facts */
|
||||
.facts {
|
||||
display: flex;
|
||||
@@ -470,42 +428,6 @@
|
||||
}
|
||||
.fact-text strong { color: #fff; font-weight: 400; }
|
||||
|
||||
/* Continent bar */
|
||||
.cont-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.cont-bar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cont-seg { border-radius: 3px; min-width: 4px; }
|
||||
.cont-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
.cont-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 300;
|
||||
color: rgba(255,255,255,0.5);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cont-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
|
||||
@@ -227,15 +227,4 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pc-cta {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #a5b4fc;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,11 +82,11 @@
|
||||
{:else}
|
||||
<div class="sort-row">
|
||||
<span class="sort-label">Sort by</span>
|
||||
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
|
||||
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
|
||||
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
|
||||
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A → Z</option>
|
||||
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z → A</option>
|
||||
<select class="sort-select" bind:value={sortKey}>
|
||||
<option value="date-desc">Newest first</option>
|
||||
<option value="date-asc">Oldest first</option>
|
||||
<option value="country-asc">Country A → Z</option>
|
||||
<option value="country-desc">Country Z → A</option>
|
||||
</select>
|
||||
</div>
|
||||
<ol class="v-list">
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
import { get } from 'svelte/store';
|
||||
import { journals } from '../stores/entriesStore.svelte.js';
|
||||
import { getUserProfile } from '../auth/userStore.svelte.js';
|
||||
import { nameToId } from '../shared/countries.js';
|
||||
import airplaneImg from '../../assets/airplane-animation.png';
|
||||
|
||||
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
||||
|
||||
const HOME_CODE = '203';
|
||||
const HOME_CODE = $derived(nameToId[getUserProfile()?.homeCountry] ?? null);
|
||||
|
||||
const PLANE_SIZE = 26;
|
||||
|
||||
@@ -291,23 +293,14 @@
|
||||
renderMap();
|
||||
|
||||
const nameToId = Object.fromEntries(Object.entries(featuresById).filter(([,f]) => f.properties?.name).map(([id, f]) => [f.properties.name, id]));
|
||||
const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
|
||||
const trips = entries.length > 0
|
||||
? entries.map(e => ({
|
||||
countryName: e.location.country,
|
||||
countryCode: nameToId[e.location.country] ?? null,
|
||||
city: e.location.cities?.[0] ?? e.location.country,
|
||||
transport: e.transport ?? 'flight',
|
||||
date: e.date,
|
||||
})).filter(t => t.countryCode)
|
||||
: [
|
||||
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' },
|
||||
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' },
|
||||
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' },
|
||||
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' },
|
||||
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' },
|
||||
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' },
|
||||
];
|
||||
const entries = get(journals).slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||
const trips = entries.map(e => ({
|
||||
countryName: e.location.country,
|
||||
countryCode: nameToId[e.location.country] ?? null,
|
||||
city: e.location.cities?.[0] ?? e.location.country,
|
||||
transport: e.transport ?? 'flight',
|
||||
date: e.date,
|
||||
})).filter(t => t.countryCode);
|
||||
|
||||
for (let i = 0; i < trips.length; i++) {
|
||||
if (isCancelled || myId !== animId) break;
|
||||
|
||||
Reference in New Issue
Block a user