added logo image and slogan to sign in page
BIN
public/plane 2.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
@@ -62,13 +62,14 @@
|
||||
<button class="journey-play-btn" onclick={startJourney}>▶</button>
|
||||
{/if}
|
||||
</div>
|
||||
<StatsPanel />
|
||||
{#if !journeyActive}<StatsPanel />{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<TimelineView
|
||||
onDetailChange={(v) => (inDetail = v)}
|
||||
{pendingCountry}
|
||||
onNewEntryClear={() => (pendingCountry = '')}
|
||||
onGoToMap={() => { screen = 'worldmap'; }}
|
||||
/>
|
||||
{/if}
|
||||
</Layout>
|
||||
@@ -139,4 +140,5 @@
|
||||
.journey-play-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
BIN
src/assets 2/airplane 2.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
src/assets 2/bus 2.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
src/assets 2/car 2.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
src/assets 2/ship 2.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
src/assets 2/train 2.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
src/assets 2/walk 2.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src/assets/logo-1.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
src/assets/logo-2.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
@@ -1,12 +1,13 @@
|
||||
<script>
|
||||
import { signInWithGoogle } from './userStore.svelte.js';
|
||||
import logoImg from '../../assets/logo.png';
|
||||
</script>
|
||||
|
||||
<div class="overlay">
|
||||
<div class="card">
|
||||
<img src="/logo.png" alt="Map Journal" class="logo" />
|
||||
<h1 class="title">Map Journal</h1>
|
||||
<p class="subtitle">Sign in to start your journey</p>
|
||||
<img src={logoImg} alt="Journi" class="logo" />
|
||||
<h1 class="title">Journi</h1>
|
||||
<p class="subtitle">Collect Colors Along the Way</p>
|
||||
<button class="google-btn" onclick={signInWithGoogle}>
|
||||
<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"/>
|
||||
@@ -24,64 +25,71 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e2937;
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
max-width: 400px;
|
||||
max-width: 360px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
width: 216px;
|
||||
height: 216px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: 700 28px/1.2 sans-serif;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--heading);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
letter-spacing: -0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font: 400 15px/1.4 sans-serif;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 32px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.google-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 28px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
gap: 10px;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
font: 500 16px/1.4 sans-serif;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-h);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.google-btn:hover {
|
||||
background: #475569;
|
||||
background: var(--bg);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { auth, db, googleProvider } from '../firebase.js';
|
||||
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
|
||||
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
|
||||
import { initSelectionListener } from '../layout/selection.svelte.js';
|
||||
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
|
||||
|
||||
let _initialized = false;
|
||||
@@ -48,7 +47,6 @@ export function initAuth() {
|
||||
onAuthStateChanged(auth, async (fbUser) => {
|
||||
if (fbUser) {
|
||||
user = fbUser;
|
||||
initSelectionListener(fbUser.uid);
|
||||
initEntriesListener(fbUser.uid);
|
||||
const docRef = doc(db, 'users', fbUser.uid);
|
||||
const docSnap = await getDoc(docRef);
|
||||
|
||||
19
src/lib/firebase 2.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import { getStorage } from 'firebase/storage';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
export const app = initializeApp(firebaseConfig);
|
||||
export const auth = getAuth(app);
|
||||
export const db = getFirestore(app);
|
||||
export const storage = getStorage(app);
|
||||
export const googleProvider = new GoogleAuthProvider();
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
|
||||
import logo1Img from '../../assets/logo-1.png';
|
||||
|
||||
let { screen, onNavigate } = $props();
|
||||
|
||||
@@ -20,7 +21,10 @@
|
||||
|
||||
<div class="topbar">
|
||||
<div class="left">
|
||||
<span class="app-name">Journi</span>
|
||||
<div class="brand">
|
||||
<img src={logo1Img} class="logo-img" alt="Journi logo" />
|
||||
<span class="app-name">Journi</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
@@ -83,6 +87,18 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-family: var(--heading);
|
||||
font-size: 22px;
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
import { db } from '../firebase.js';
|
||||
import { doc, onSnapshot, setDoc, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
|
||||
import { journals } from '../stores/journalStore.js';
|
||||
import { nameToId } from '../shared/countries.js';
|
||||
|
||||
let selected = $state(new Set());
|
||||
let totalCountries = $state(0);
|
||||
let homeCountryCode = $state(null);
|
||||
let _uid = null;
|
||||
let _unsubscribe = null;
|
||||
|
||||
export function initSelectionListener(uid) {
|
||||
if (_unsubscribe) _unsubscribe();
|
||||
_uid = uid;
|
||||
const userRef = doc(db, 'users', uid);
|
||||
_unsubscribe = onSnapshot(userRef, (snap) => {
|
||||
if (snap.exists()) {
|
||||
const codes = snap.data().visitedCountries || [];
|
||||
selected = new Set(codes);
|
||||
homeCountryCode = snap.data().homeCountryCode || null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const visitedRef = doc(db, 'visited', 'countries');
|
||||
|
||||
onSnapshot(visitedRef, (snap) => {
|
||||
if (snap.exists()) {
|
||||
selected = new Set(snap.data().ids ?? []);
|
||||
// Derive visited countries from journal entries
|
||||
journals.subscribe((entries) => {
|
||||
const ids = new Set();
|
||||
for (const e of entries) {
|
||||
const id = nameToId[e.location?.country];
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
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() {
|
||||
return selected;
|
||||
}
|
||||
@@ -66,7 +25,3 @@ export function setTotalCount(n) {
|
||||
export function getTotalCount() {
|
||||
return totalCountries;
|
||||
}
|
||||
|
||||
export function getHomeCountryCode() {
|
||||
return homeCountryCode;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Searchable combobox input.
|
||||
* @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 open = $state(false);
|
||||
@@ -39,7 +39,11 @@
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -63,8 +63,18 @@ const nameToAlpha2 = {
|
||||
'eSwatini':'SZ','Åland':'AX',
|
||||
};
|
||||
|
||||
export const countryNames = feature(worldData, worldData.objects.countries)
|
||||
.features.map(f => f.properties?.name).filter(Boolean).sort();
|
||||
const _features = feature(worldData, worldData.objects.countries).features;
|
||||
|
||||
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 */
|
||||
export function flagEmoji(country) {
|
||||
|
||||
45
src/lib/stores/entriesStore.svelte 2.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { db } from '../firebase.js';
|
||||
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
|
||||
|
||||
let entries = $state([]);
|
||||
let _uid = null;
|
||||
let _unsubscribe = null;
|
||||
|
||||
export function getEntries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function initEntriesListener(uid) {
|
||||
if (_unsubscribe) _unsubscribe();
|
||||
_uid = uid;
|
||||
const q = query(
|
||||
collection(db, 'users', uid, 'entries'),
|
||||
orderBy('createdAt', 'desc')
|
||||
);
|
||||
_unsubscribe = onSnapshot(q, (snap) => {
|
||||
entries = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function addEntry(data) {
|
||||
if (!_uid) return null;
|
||||
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
|
||||
...data,
|
||||
createdAt: serverTimestamp(),
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
export async function updateEntry(id, data) {
|
||||
if (!_uid) return;
|
||||
await updateDoc(doc(db, 'users', _uid, 'entries', id), {
|
||||
...data,
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeEntry(id) {
|
||||
if (!_uid) return;
|
||||
await deleteDoc(doc(db, 'users', _uid, 'entries', id));
|
||||
}
|
||||
454
src/lib/timeline 2/NewEntryForm 2.svelte
Normal file
@@ -0,0 +1,454 @@
|
||||
<script>
|
||||
import { get } from 'svelte/store';
|
||||
import { journals, addJournal } from '../stores/journalStore.js';
|
||||
import { countryNames } from '../shared/countries.js';
|
||||
import SearchInput from '../shared/SearchInput.svelte';
|
||||
import PhotoEditor from './PhotoEditor.svelte';
|
||||
|
||||
let { initialCountry = '', onBack } = $props();
|
||||
|
||||
// ── Fields ─────────────────────────────────────────────────────────
|
||||
let cities = $state([]);
|
||||
let cityInput = $state('');
|
||||
let country = $state(initialCountry);
|
||||
let date = $state(new Date().toISOString().slice(0, 10));
|
||||
let days = $state('');
|
||||
let tripType = $state('');
|
||||
let transport = $state('');
|
||||
let photos = $state([]);
|
||||
let answers = $state(['', '', '']);
|
||||
|
||||
let errors = $state({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' });
|
||||
|
||||
// ── Steps ──────────────────────────────────────────────────────────
|
||||
let step = $state(1); // 1 | 2 | 3
|
||||
|
||||
// ── Random questions ───────────────────────────────────────────────
|
||||
const ALL_QUESTIONS = [
|
||||
'If this trip had a movie title, what would it be?',
|
||||
'What was the most unexpected thing that happened?',
|
||||
'Which moment would you relive for just 10 more minutes?',
|
||||
'What was your best accidental discovery?\n(A café, a street, a person, a view…)',
|
||||
'If your trip had a theme song, what would it sound like?',
|
||||
'What did you pack but never use?',
|
||||
'What was the smallest thing that made you surprisingly happy?',
|
||||
'If you could steal one thing from this place (without consequences), what would it be?\n(A tradition, a smell, a sunset, a food…)',
|
||||
'What story from this trip will you probably tell your friends first?',
|
||||
'What version of yourself showed up on this trip?',
|
||||
];
|
||||
|
||||
function pickRandom() {
|
||||
const shuffled = [...ALL_QUESTIONS].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, 3);
|
||||
}
|
||||
|
||||
const questions = pickRandom();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
// Suggest cities — if a country is selected, show cities only from that country;
|
||||
// otherwise show all known cities.
|
||||
let cityOptions = $derived(
|
||||
country.trim()
|
||||
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
|
||||
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
|
||||
);
|
||||
|
||||
function addCity(val) {
|
||||
const t = (val ?? cityInput).trim();
|
||||
if (t && !cities.includes(t)) cities = [...cities, t];
|
||||
cityInput = '';
|
||||
}
|
||||
|
||||
function removeCity(c) { cities = cities.filter(x => x !== c); }
|
||||
|
||||
$effect(() => { if (country.trim()) errors.country = ''; });
|
||||
$effect(() => { if (cities.length > 0) errors.cities = ''; });
|
||||
$effect(() => { if (date) errors.date = ''; });
|
||||
$effect(() => { if (days && Number(days) >= 1) errors.days = ''; });
|
||||
$effect(() => { if (tripType) errors.tripType = ''; });
|
||||
$effect(() => { if (transport) errors.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' },
|
||||
];
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────
|
||||
function nextStep() {
|
||||
if (step === 1) {
|
||||
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
|
||||
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--;
|
||||
}
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
let saving = $state(false);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
const memo = questions
|
||||
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
try {
|
||||
await addJournal({
|
||||
title: `${cities.join(', ')}, ${country}`,
|
||||
date,
|
||||
days: Number(days),
|
||||
tripType,
|
||||
transport,
|
||||
memo,
|
||||
photos,
|
||||
location: { cities, country },
|
||||
});
|
||||
onBack();
|
||||
} catch {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="layout">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="ghost-btn" onclick={prevStep}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
{step === 1 ? 'Back' : 'Previous'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
{#each [1,2,3] as s}
|
||||
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
{#if step < 3}
|
||||
<button class="save-btn" onclick={nextStep}>Next</button>
|
||||
{:else}
|
||||
<button class="save-btn" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save trip'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="form">
|
||||
|
||||
{#if step === 1}
|
||||
<!-- ── STEP 1: Details ── -->
|
||||
<h2 class="step-title">Trip details</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label class="label" for="nc-country">Country <span class="req">*</span></label>
|
||||
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
|
||||
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
|
||||
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
||||
{#if cities.length > 0}
|
||||
<div class="tags">
|
||||
{#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="nc-date">Date <span class="req">*</span></label>
|
||||
<input id="nc-date" class="input" type="date" bind:value={date} />
|
||||
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="nc-days">Days <span class="req">*</span></label>
|
||||
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
|
||||
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Trip type <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="nc-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 get there? <span class="req">*</span></label>
|
||||
<div class="transport-grid">
|
||||
{#each transportOptions as opt}
|
||||
<label class="transport-opt" class:active={transport === opt.value}>
|
||||
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
|
||||
</div>
|
||||
|
||||
{:else if step === 2}
|
||||
<!-- ── STEP 2: Photos ── -->
|
||||
<h2 class="step-title">Photos</h2>
|
||||
<p class="step-sub">Optional — add photos from your trip</p>
|
||||
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||||
|
||||
{:else}
|
||||
<!-- ── STEP 3: Questions ── -->
|
||||
<h2 class="step-title">Your memories</h2>
|
||||
|
||||
{#each questions as q, i}
|
||||
<div class="q-card">
|
||||
<p class="q-text">{q}</p>
|
||||
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
/* topbar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 110px;
|
||||
}
|
||||
.topbar-right { justify-content: flex-end; }
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.step-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
.step-dot.active {
|
||||
background: var(--accent);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.step-dot.done {
|
||||
background: var(--accent);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
|
||||
|
||||
.save-btn {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* scroll + form */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* fields (same as EditForm) */
|
||||
.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-sub);
|
||||
}
|
||||
.req { color: var(--accent); font-size: 11px; }
|
||||
|
||||
.ferr { font-size: 11px; 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: 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); background: var(--bg-subtle);
|
||||
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.transport-opt input { display: none; }
|
||||
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.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; }
|
||||
|
||||
/* question cards */
|
||||
.q-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.q-text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-h);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.q-input {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text-h);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
line-height: 1.6;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.q-input:focus { border-color: var(--accent-border); }
|
||||
.q-input::placeholder { color: var(--text-sub); font-style: italic; }
|
||||
</style>
|
||||
465
src/lib/timeline 2/NewEntryForm 3.svelte
Normal file
@@ -0,0 +1,465 @@
|
||||
<script>
|
||||
import { get } from 'svelte/store';
|
||||
import { journals, addJournal } from '../stores/journalStore.js';
|
||||
import { countryNames } from '../shared/countries.js';
|
||||
import SearchInput from '../shared/SearchInput.svelte';
|
||||
import PhotoEditor from './PhotoEditor.svelte';
|
||||
import airplaneImg from '../../assets/airplane.png';
|
||||
import trainImg from '../../assets/train.png';
|
||||
import busImg from '../../assets/bus.png';
|
||||
import carImg from '../../assets/car.png';
|
||||
import shipImg from '../../assets/ship.png';
|
||||
import walkImg from '../../assets/walk.png';
|
||||
|
||||
let { initialCountry = '', onBack } = $props();
|
||||
|
||||
// ── Fields ─────────────────────────────────────────────────────────
|
||||
let cities = $state([]);
|
||||
let cityInput = $state('');
|
||||
let country = $state(initialCountry);
|
||||
let date = $state(new Date().toISOString().slice(0, 10));
|
||||
let days = $state('');
|
||||
let tripType = $state('');
|
||||
let transport = $state('');
|
||||
let photos = $state([]);
|
||||
let answers = $state(['', '', '']);
|
||||
|
||||
let errors = $state({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' });
|
||||
|
||||
// ── Steps ──────────────────────────────────────────────────────────
|
||||
let step = $state(1); // 1 | 2 | 3
|
||||
|
||||
// ── Random questions ───────────────────────────────────────────────
|
||||
const ALL_QUESTIONS = [
|
||||
'If this trip had a movie title, what would it be?',
|
||||
'What was the most unexpected thing that happened?',
|
||||
'Which moment would you relive for just 10 more minutes?',
|
||||
'What was your best accidental discovery?\n(A café, a street, a person, a view…)',
|
||||
'If your trip had a theme song, what would it sound like?',
|
||||
'What did you pack but never use?',
|
||||
'What was the smallest thing that made you surprisingly happy?',
|
||||
'If you could steal one thing from this place (without consequences), what would it be?\n(A tradition, a smell, a sunset, a food…)',
|
||||
'What story from this trip will you probably tell your friends first?',
|
||||
'What version of yourself showed up on this trip?',
|
||||
];
|
||||
|
||||
function pickRandom() {
|
||||
const shuffled = [...ALL_QUESTIONS].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, 3);
|
||||
}
|
||||
|
||||
const questions = pickRandom();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
// Suggest cities — if a country is selected, show cities only from that country;
|
||||
// otherwise show all known cities.
|
||||
let cityOptions = $derived(
|
||||
country.trim()
|
||||
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
|
||||
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
|
||||
);
|
||||
|
||||
function addCity(val) {
|
||||
const t = (val ?? cityInput).trim();
|
||||
if (t && !cities.includes(t)) cities = [...cities, t];
|
||||
cityInput = '';
|
||||
}
|
||||
|
||||
function removeCity(c) { cities = cities.filter(x => x !== c); }
|
||||
|
||||
$effect(() => { if (country.trim()) errors.country = ''; });
|
||||
$effect(() => { if (cities.length > 0) errors.cities = ''; });
|
||||
$effect(() => { if (date) errors.date = ''; });
|
||||
$effect(() => { if (days && Number(days) >= 1) errors.days = ''; });
|
||||
$effect(() => { if (tripType) errors.tripType = ''; });
|
||||
$effect(() => { if (transport) errors.transport = ''; });
|
||||
|
||||
const transportOptions = [
|
||||
{ value: 'flight', label: 'Flight', img: airplaneImg },
|
||||
{ value: 'train', label: 'Train', img: trainImg },
|
||||
{ value: 'bus', label: 'Bus', img: busImg },
|
||||
{ value: 'car', label: 'Car', img: carImg },
|
||||
{ value: 'ship', label: 'Ship', img: shipImg },
|
||||
{ value: 'walk', label: 'Walk', img: walkImg },
|
||||
];
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────
|
||||
function nextStep() {
|
||||
if (step === 1) {
|
||||
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
|
||||
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--;
|
||||
}
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
let saving = $state(false);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
const memo = questions
|
||||
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
try {
|
||||
await addJournal({
|
||||
title: `${cities.join(', ')}, ${country}`,
|
||||
date,
|
||||
days: Number(days),
|
||||
tripType,
|
||||
transport,
|
||||
memo,
|
||||
photos,
|
||||
location: { cities, country },
|
||||
});
|
||||
onBack();
|
||||
} catch {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="layout">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="ghost-btn" onclick={prevStep}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
{step === 1 ? 'Back' : 'Previous'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
{#each [1,2,3] as s}
|
||||
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
{#if step < 3}
|
||||
<button class="save-btn" onclick={nextStep}>Next</button>
|
||||
{:else}
|
||||
<button class="save-btn" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save trip'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="form">
|
||||
|
||||
{#if step === 1}
|
||||
<!-- ── STEP 1: Details ── -->
|
||||
<h2 class="step-title">Trip details</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label class="label" for="nc-country">Country <span class="req">*</span></label>
|
||||
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
|
||||
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
|
||||
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
||||
{#if cities.length > 0}
|
||||
<div class="tags">
|
||||
{#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="nc-date">Date <span class="req">*</span></label>
|
||||
<input id="nc-date" class="input" type="date" bind:value={date} />
|
||||
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="nc-days">Days <span class="req">*</span></label>
|
||||
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
|
||||
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Trip type <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="nc-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 get there? <span class="req">*</span></label>
|
||||
<div class="transport-grid">
|
||||
{#each transportOptions as opt}
|
||||
<label class="transport-opt" class:active={transport === opt.value}>
|
||||
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
|
||||
<img src={opt.img} alt={opt.label} class="transport-img" />
|
||||
<span class="transport-label">{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
|
||||
</div>
|
||||
|
||||
{:else if step === 2}
|
||||
<!-- ── STEP 2: Photos ── -->
|
||||
<h2 class="step-title">Photos</h2>
|
||||
<p class="step-sub">Optional — add photos from your trip</p>
|
||||
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
|
||||
|
||||
{:else}
|
||||
<!-- ── STEP 3: Questions ── -->
|
||||
<h2 class="step-title">Your memories</h2>
|
||||
|
||||
{#each questions as q, i}
|
||||
<div class="q-card">
|
||||
<p class="q-text">{q}</p>
|
||||
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
/* topbar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 110px;
|
||||
}
|
||||
.topbar-right { justify-content: flex-end; }
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.step-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
.step-dot.active {
|
||||
background: var(--accent);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.step-dot.done {
|
||||
background: var(--accent);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
|
||||
|
||||
.save-btn {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* scroll + form */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* fields (same as EditForm) */
|
||||
.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-sub);
|
||||
}
|
||||
.req { color: var(--accent); font-size: 11px; }
|
||||
|
||||
.ferr { font-size: 11px; 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: 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; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 8px; aspect-ratio: 1;
|
||||
border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle);
|
||||
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.transport-opt input { display: none; }
|
||||
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); }
|
||||
.transport-img { width: 60px; height: 60px; object-fit: contain; }
|
||||
.transport-label {
|
||||
font-size: 12px; font-weight: 300; color: var(--text-sub);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.transport-opt.active .transport-label { color: var(--accent); }
|
||||
|
||||
.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; }
|
||||
|
||||
/* question cards */
|
||||
.q-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.q-text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-h);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.q-input {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--text-h);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
line-height: 1.6;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.q-input:focus { border-color: var(--accent-border); }
|
||||
.q-input::placeholder { color: var(--text-sub); font-style: italic; }
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@
|
||||
import shipImg from '../../assets/ship.png';
|
||||
import walkImg from '../../assets/walk.png';
|
||||
|
||||
let { initialCountry = '', onBack } = $props();
|
||||
let { initialCountry = '', onBack, onSaved = onBack } = $props();
|
||||
|
||||
// ── Fields ─────────────────────────────────────────────────────────
|
||||
let cities = $state([]);
|
||||
@@ -124,7 +124,7 @@
|
||||
photos,
|
||||
location: { cities, country },
|
||||
});
|
||||
onBack();
|
||||
onSaved();
|
||||
} catch {
|
||||
saving = false;
|
||||
}
|
||||
@@ -172,7 +172,7 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
|
||||
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} onblurcommit={addCity} />
|
||||
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
||||
{#if cities.length > 0}
|
||||
<div class="tags">
|
||||
@@ -369,6 +369,25 @@
|
||||
|
||||
.ferr { font-size: 11px; color: #dc2626; }
|
||||
|
||||
.combo-select, .city-text {
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: var(--text-h);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
.combo-select:focus, .city-text:focus { border-color: var(--accent-border); }
|
||||
.combo-select { margin-bottom: 6px; cursor: pointer; }
|
||||
.city-text { margin-top: 0; }
|
||||
|
||||
.input {
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import ShareCard from './ShareCard.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 view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
|
||||
let showShare = $state(false);
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
{#if view === 'new'}
|
||||
<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>
|
||||
{:else if view === 'edit' && selected}
|
||||
<div class="detail-scroll">
|
||||
@@ -275,38 +275,4 @@
|
||||
}
|
||||
.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>
|
||||
|
||||
391
src/lib/world-map/JourneyView 2.svelte
Normal file
@@ -0,0 +1,391 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as d3 from 'd3';
|
||||
import { feature } from 'topojson-client';
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
|
||||
let { onclose, onprogress } = $props();
|
||||
|
||||
const HOME_CODE = '203';
|
||||
|
||||
const MOCK_TRIPS = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const HOME_COLOR = '#8b5cf6';
|
||||
const VISITED_COLOR = '#22c55e';
|
||||
const ARC_COLOR = '#000000';
|
||||
const PLANE_COLOR = '#7c3aed';
|
||||
const PLANE_PATH = 'M14,0 L4,-3 L0,-7 L-3,-5 L0,-2 L-5,-1 L-9,-5 L-11,-4 L-7,0 L-11,4 L-9,5 L-5,1 L0,2 L-3,5 L0,7 L4,3 Z';
|
||||
const UNVISITED = '#ffffff';
|
||||
|
||||
const TERRITORY_PARENT = {
|
||||
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
|
||||
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
|
||||
'258': '250', '260': '250', '304': '208', '316': '840', '334': '036',
|
||||
'446': '156', '500': '826', '531': '528', '533': '528', '534': '528',
|
||||
'540': '250', '570': '554', '574': '036', '580': '840', '612': '826',
|
||||
'630': '840', '652': '250', '654': '826', '660': '826', '663': '250',
|
||||
'666': '250', '796': '826', '831': '826', '832': '826', '833': '826',
|
||||
'850': '840', '876': '250',
|
||||
};
|
||||
|
||||
function effId(d) {
|
||||
return TERRITORY_PARENT[d.id] || d.id;
|
||||
}
|
||||
|
||||
let frameEl;
|
||||
let svg, g, pathFn, projection;
|
||||
let countryPaths;
|
||||
let homeFeature;
|
||||
let featuresById = {};
|
||||
let isCancelled = false;
|
||||
let isPlaying = $state(false);
|
||||
let isFinished = $state(false);
|
||||
|
||||
function fitProjection(proj, w, h) {
|
||||
proj.fitSize([w, h], { type: 'Sphere' });
|
||||
const s = proj.scale() * 1.5;
|
||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||
}
|
||||
|
||||
function computeArc(p1, p2) {
|
||||
const interp = d3.geoInterpolate(p1, p2);
|
||||
const steps = 80;
|
||||
const raw = [];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const geo = interp(t);
|
||||
const pt = projection(geo);
|
||||
if (!pt) continue;
|
||||
raw.push({ t, x: pt[0], y: pt[1] });
|
||||
}
|
||||
|
||||
if (raw.length < 2) return [];
|
||||
|
||||
const first = raw[0];
|
||||
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));
|
||||
|
||||
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 animateStroke(pathEl, tipEl, startOffset, endOffset, duration) {
|
||||
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);
|
||||
|
||||
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', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1);
|
||||
} catch (e) {
|
||||
// ignore SVG errors
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
resolve();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => {
|
||||
if (isCancelled) { resolve(); return; }
|
||||
const id = setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function animateTrip(destCode, destFeature) {
|
||||
if (!homeFeature || !destFeature) return;
|
||||
|
||||
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||
const destCentroid = d3.geoCentroid(destFeature);
|
||||
|
||||
const pts = computeArc(homeCentroid, destCentroid);
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const lineGen = d3.line().curve(d3.curveBasis);
|
||||
const pathData = lineGen(pts);
|
||||
|
||||
if (!pathData) return;
|
||||
|
||||
function createArc(pathData) {
|
||||
const el = g.append('path')
|
||||
.attr('d', pathData)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', ARC_COLOR)
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-linecap', 'round');
|
||||
const tip = g.append('path')
|
||||
.attr('d', PLANE_PATH)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('opacity', 0);
|
||||
return { el, tip };
|
||||
}
|
||||
|
||||
// --- Outbound: home -> dest ---
|
||||
let { el: outEl, tip: outTip } = createArc(pathData);
|
||||
const outLen = outEl.node().getTotalLength();
|
||||
outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen);
|
||||
|
||||
const homeDot = g.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('cx', pts[0][0])
|
||||
.attr('cy', pts[0][1])
|
||||
.attr('opacity', 1);
|
||||
|
||||
await animateStroke(outEl, outTip, outLen, 0, 2500);
|
||||
if (isCancelled) return;
|
||||
|
||||
outEl.remove();
|
||||
outTip.remove();
|
||||
homeDot.remove();
|
||||
|
||||
// Color the destination country
|
||||
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
||||
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
||||
g.selectAll('.micro-state-j')
|
||||
.filter(d => effId(d) === destCode)
|
||||
.transition().duration(500)
|
||||
.attr('fill', VISITED_COLOR);
|
||||
|
||||
await delay(800);
|
||||
if (isCancelled) return;
|
||||
|
||||
// --- Return: dest -> home ---
|
||||
const revPts = [...pts].reverse();
|
||||
const revData = d3.line().curve(d3.curveBasis)(revPts);
|
||||
let { el: retEl, tip: retTip } = createArc(revData);
|
||||
const retLen = retEl.node().getTotalLength();
|
||||
retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen);
|
||||
|
||||
const destDot = g.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('cx', revPts[0][0])
|
||||
.attr('cy', revPts[0][1])
|
||||
.attr('opacity', 1);
|
||||
|
||||
await animateStroke(retEl, retTip, retLen, 0, 2200);
|
||||
if (isCancelled) return;
|
||||
|
||||
retEl.remove();
|
||||
retTip.remove();
|
||||
destDot.remove();
|
||||
|
||||
await delay(300);
|
||||
}
|
||||
|
||||
async function startJourney() {
|
||||
isPlaying = true;
|
||||
isFinished = false;
|
||||
isCancelled = false;
|
||||
|
||||
const trips = MOCK_TRIPS;
|
||||
const total = trips.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (isCancelled) break;
|
||||
|
||||
const trip = trips[i];
|
||||
const destFeature = featuresById[trip.countryCode];
|
||||
if (!destFeature) continue;
|
||||
|
||||
const label = `${trip.city}, ${trip.countryName}`;
|
||||
if (onprogress) onprogress({ index: i + 1, total, label });
|
||||
|
||||
await animateTrip(trip.countryCode, destFeature);
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
isFinished = true;
|
||||
isPlaying = false;
|
||||
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
||||
} else {
|
||||
isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopJourney() {
|
||||
isCancelled = true;
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const width = frameEl.clientWidth;
|
||||
const height = frameEl.clientHeight;
|
||||
|
||||
projection = d3.geoMercator();
|
||||
fitProjection(projection, width, height);
|
||||
|
||||
pathFn = d3.geoPath().projection(projection);
|
||||
|
||||
const countries = feature(worldData, worldData.objects.countries)
|
||||
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
||||
|
||||
countries.forEach(f => {
|
||||
if (!f.id) f.id = 'XK';
|
||||
});
|
||||
|
||||
for (const f of countries) {
|
||||
featuresById[effId(f)] = f;
|
||||
}
|
||||
|
||||
homeFeature = featuresById[HOME_CODE];
|
||||
|
||||
svg = d3.select(frameEl)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.style('cursor', 'default');
|
||||
|
||||
g = svg.append('g');
|
||||
|
||||
countryPaths = g.selectAll('path')
|
||||
.data(countries)
|
||||
.join('path')
|
||||
.attr('d', pathFn)
|
||||
.attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
|
||||
.attr('stroke', '#d4d4d4')
|
||||
.attr('stroke-width', 0.5);
|
||||
|
||||
function renderMicrostates() {
|
||||
g.selectAll('.micro-state-j').remove();
|
||||
const threshold = 4;
|
||||
countryPaths.each(function (d) {
|
||||
if (effId(d) !== d.id) return;
|
||||
const { width, height } = this.getBBox();
|
||||
if (width < threshold && height < threshold) {
|
||||
const [cx, cy] = pathFn.centroid(d);
|
||||
g.append('circle')
|
||||
.attr('class', 'micro-state-j')
|
||||
.datum(d)
|
||||
.attr('cx', cx)
|
||||
.attr('cy', cy)
|
||||
.attr('r', 2)
|
||||
.attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-width', 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderMicrostates();
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
svg.attr('width', width).attr('height', height);
|
||||
fitProjection(projection, width, height);
|
||||
countryPaths.attr('d', pathFn);
|
||||
renderMicrostates();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(frameEl);
|
||||
|
||||
startJourney();
|
||||
|
||||
return () => {
|
||||
stopJourney();
|
||||
observer.disconnect();
|
||||
if (svg) svg.remove();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={frameEl} class="journey-frame">
|
||||
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
|
||||
{#if isFinished}
|
||||
<div class="done-badge">Journey complete!</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.journey-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #a4c8e0;
|
||||
}
|
||||
|
||||
.journey-frame :global(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.65);
|
||||
color: #fff;
|
||||
font-family: var(--heading, sans-serif);
|
||||
font-size: 16px;
|
||||
padding: 10px 24px;
|
||||
border-radius: 24px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
@@ -3,19 +3,27 @@
|
||||
import * as d3 from 'd3';
|
||||
import { feature } from 'topojson-client';
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
import { get } from 'svelte/store';
|
||||
import { journals } from '../stores/journalStore.js';
|
||||
import airplaneImg from '../../assets/airplane.png';
|
||||
import trainImg from '../../assets/train.png';
|
||||
import busImg from '../../assets/bus.png';
|
||||
import carImg from '../../assets/car.png';
|
||||
import shipImg from '../../assets/ship.png';
|
||||
import walkImg from '../../assets/walk.png';
|
||||
|
||||
let { onclose, onprogress } = $props();
|
||||
|
||||
const HOME_CODE = '203';
|
||||
|
||||
const MOCK_TRIPS = [
|
||||
{ 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' },
|
||||
];
|
||||
const TRANSPORT_IMG = {
|
||||
flight: airplaneImg,
|
||||
train: trainImg,
|
||||
bus: busImg,
|
||||
car: carImg,
|
||||
ship: shipImg,
|
||||
walk: walkImg,
|
||||
};
|
||||
|
||||
const HOME_COLOR = '#8b5cf6';
|
||||
const VISITED_COLOR = '#22c55e';
|
||||
@@ -128,7 +136,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function animateTrip(destCode, destFeature) {
|
||||
async function animateTrip(destCode, destFeature, transport = 'flight') {
|
||||
if (!homeFeature || !destFeature) return;
|
||||
|
||||
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||
@@ -142,6 +150,10 @@
|
||||
|
||||
if (!pathData) return;
|
||||
|
||||
const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg;
|
||||
const iconSize = 28;
|
||||
const iconHalf = iconSize / 2;
|
||||
|
||||
function createArc(pathData) {
|
||||
const el = g.append('path')
|
||||
.attr('d', pathData)
|
||||
@@ -150,9 +162,12 @@
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-linecap', 'round');
|
||||
const tip = g.append('path')
|
||||
.attr('d', PLANE_PATH)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
const tip = g.append('image')
|
||||
.attr('href', iconSrc)
|
||||
.attr('width', iconSize)
|
||||
.attr('height', iconSize)
|
||||
.attr('x', -iconHalf)
|
||||
.attr('y', -iconHalf)
|
||||
.attr('opacity', 0);
|
||||
return { el, tip };
|
||||
}
|
||||
@@ -216,7 +231,30 @@
|
||||
isFinished = false;
|
||||
isCancelled = false;
|
||||
|
||||
const trips = MOCK_TRIPS;
|
||||
// Build name → numeric ID map from loaded features
|
||||
const nameToId = {};
|
||||
for (const [id, feat] of Object.entries(featuresById)) {
|
||||
if (feat.properties?.name) nameToId[feat.properties.name] = id;
|
||||
}
|
||||
|
||||
// Use real journal entries, sorted by date
|
||||
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',
|
||||
})).filter(t => t.countryCode)
|
||||
: [
|
||||
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight' },
|
||||
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight' },
|
||||
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight' },
|
||||
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight' },
|
||||
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight' },
|
||||
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight' },
|
||||
];
|
||||
|
||||
const total = trips.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
@@ -229,7 +267,7 @@
|
||||
const label = `${trip.city}, ${trip.countryName}`;
|
||||
if (onprogress) onprogress({ index: i + 1, total, label });
|
||||
|
||||
await animateTrip(trip.countryCode, destFeature);
|
||||
await animateTrip(trip.countryCode, destFeature, trip.transport);
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
@@ -351,14 +389,14 @@
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,0.55);
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
@@ -366,11 +404,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease;
|
||||
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.4);
|
||||
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
background: #7c3aed;
|
||||
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
|
||||
}
|
||||
|
||||
.close-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as d3 from 'd3';
|
||||
import { feature } from 'topojson-client';
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
import { getSelected, toggle, setTotalCount, getHomeCountryCode } from '../layout/selection.svelte.js';
|
||||
import { getSelected, setTotalCount } from '../layout/selection.svelte.js';
|
||||
|
||||
let { onCountryClick = (_name) => {} } = $props();
|
||||
|
||||
@@ -84,10 +84,9 @@
|
||||
|
||||
function updateAllFills() {
|
||||
const sel = getSelected();
|
||||
const hc = getHomeCountryCode();
|
||||
if (!_paths || !_g) return;
|
||||
_paths.attr('fill', d => countryColor(d, sel, hc));
|
||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
||||
_paths.attr('fill', d => countryColor(d, sel, null));
|
||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, null));
|
||||
}
|
||||
|
||||
$effect(updateAllFills);
|
||||
@@ -123,24 +122,14 @@
|
||||
.attr('class', 'tooltip')
|
||||
.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) {
|
||||
sel
|
||||
.on('click', (event, d) => {
|
||||
toggle(effId(d));
|
||||
updateFill(d3.select(event.currentTarget));
|
||||
onCountryClick(d.properties.name);
|
||||
})
|
||||
.on('mouseenter', (event, d) => {
|
||||
const s = getSelected();
|
||||
const hc = getHomeCountryCode();
|
||||
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
|
||||
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, null));
|
||||
tooltip.style('display', 'block').text(d.properties.name);
|
||||
})
|
||||
.on('mousemove', (event) => {
|
||||
@@ -149,8 +138,7 @@
|
||||
})
|
||||
.on('mouseleave', (event, d) => {
|
||||
const s = getSelected();
|
||||
const hc = getHomeCountryCode();
|
||||
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
|
||||
d3.select(event.currentTarget).attr('fill', countryColor(d, s, null));
|
||||
tooltip.style('display', 'none');
|
||||
});
|
||||
}
|
||||
@@ -172,14 +160,13 @@
|
||||
const { width, height } = this.getBBox();
|
||||
if (width < threshold && height < threshold) {
|
||||
const [cx, cy] = path.centroid(d);
|
||||
const hc = getHomeCountryCode();
|
||||
const c = _g.append('circle')
|
||||
.attr('class', 'micro-state')
|
||||
.datum(d)
|
||||
.attr('cx', cx)
|
||||
.attr('cy', cy)
|
||||
.attr('r', 2)
|
||||
.attr('fill', countryColor(d, getSelected(), hc))
|
||||
.attr('fill', countryColor(d, getSelected(), null))
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-width', 0.5);
|
||||
attachEvents(c);
|
||||
|
||||