13 Commits

Author SHA1 Message Date
522e41cb79 Update README.md 2026-06-17 02:24:15 +00:00
caaae887f9 fix: animation bug fix 2026-06-17 11:21:20 +09:00
3ae1b25b0c Merge branch 'main' of https://git.prototyping.id/20256426/Map-Jurnal 2026-06-17 09:00:54 +09:00
e6bc251386 final cleanup 2026-06-17 09:00:20 +09:00
4e1981e1f6 Update README.md 2026-06-16 17:15:17 +00:00
59b89fb276 Aktualizovat README.md 2026-06-16 16:50:57 +00:00
5231b0ead7 update README 2026-06-16 16:50:25 +00:00
6fc0172ec3 updated README 2026-06-17 01:45:32 +09:00
c27200d9cd Aktualizovat README.md 2026-06-16 16:26:42 +00:00
8fbe40f178 fix; timeline sorting 2026-06-17 01:20:32 +09:00
8b52437655 Merge branch 'main' of https://git.prototyping.id/20256426/Map-Jurnal 2026-06-17 01:15:52 +09:00
5e36f6e140 bug fix 2026-06-17 01:15:43 +09:00
3e43d09723 published the website 2026-06-17 01:04:45 +09:00
14 changed files with 94 additions and 186 deletions

View File

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

View File

@@ -2,15 +2,15 @@
**Authors:** Tomas Horsky, Haeri Kim
**Student IDs:** 20256426, <ID>
**Student IDs:** 20256426, 20254236
**Emails:** tomashorsky@kaist.ac.kr , <Email>
**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
---
@@ -34,6 +34,13 @@ Journi is a web application for journaling and visualizing travel experiences. U
---
## Backend — Firebase
![Firestore diagram](public/DB-diagram.PNG)
Journi uses Firebase as its backend. Users sign in with Google through Firebase Authentication, and their data is linked to their user ID. Firestore stores user profiles in `users/{uid}` and journal entries in `users/{uid}/entries/{id}`. Firebase Storage stores uploaded trip photos. Firebase Security Rules protect the data, so authenticated users can only read and write their own profiles, entries, and photos.
---
## Code Organization
```
@@ -87,35 +94,6 @@ 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
```
## Setup & Run
@@ -139,11 +117,17 @@ npm run dev
---
## Known Issues
- **Globe animation** — The D3 globe rotation animation during journey replay can be a little bit laggy.
- **Switching between animations** - after switching from map to globe animation, the old animation might be still visible for a moment. Restatarting the animation fixes the issue.
---
## Acknowledgments
- **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.
- **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)

View File

@@ -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
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -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);

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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">

View File

@@ -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;