feat: Firebase integration, 3-step new trip form, full country list, and UI polish

- Connect Firestore for journal entries and visited countries (real-time onSnapshot)
- Connect Firebase Storage for photo uploads
- Add NewEntryForm: 3-step flow (trip details → photos → reflection questions)
- Expand country list to full world-atlas dataset (~240 countries) matching the map
- Filter city suggestions by selected country in both NewEntryForm and EditForm
- Redesign StatsPanel as floating horizontal card with donut chart and progress bar
- Center timeline layout with responsive side margins
- Replace "entry" language with "trip" throughout (Add trip, Save trip, Delete trip)
- Remove footer from Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
haerikimmm
2026-06-15 19:15:31 +09:00
parent 40e75f30e8
commit dd7932ea4e
12 changed files with 884 additions and 544 deletions

View File

@@ -101,6 +101,7 @@
flex-direction: row; flex-direction: row;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
position: relative;
} }
.map-area { .map-area {

View File

@@ -1,6 +1,7 @@
import { initializeApp } from "firebase/app"; import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from "firebase/auth"; import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from "firebase/firestore"; import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
const firebaseConfig = { const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY, apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
@@ -11,8 +12,8 @@ const firebaseConfig = {
appId: import.meta.env.VITE_FIREBASE_APP_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID,
}; };
const app = initializeApp(firebaseConfig); export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app); export const auth = getAuth(app);
export const db = getFirestore(app); export const db = getFirestore(app);
export const storage = getStorage(app);
export const googleProvider = new GoogleAuthProvider(); export const googleProvider = new GoogleAuthProvider();

View File

@@ -1,6 +1,5 @@
<script> <script>
import TopBar from './TopBar.svelte'; import TopBar from './TopBar.svelte';
import Footer from './Footer.svelte';
let { screen, onNavigate, hideTopBar = false, children } = $props(); let { screen, onNavigate, hideTopBar = false, children } = $props();
</script> </script>
@@ -12,7 +11,6 @@
<main class="main"> <main class="main">
{@render children()} {@render children()}
</main> </main>
<Footer />
</div> </div>
<style> <style>
@@ -20,11 +18,11 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr;
overflow: hidden; overflow: hidden;
} }
.layout.no-topbar { .layout.no-topbar {
grid-template-rows: 1fr auto; grid-template-rows: 1fr;
} }
.main { .main {

View File

@@ -1,5 +1,5 @@
import { db } from '../firebase.js'; import { db } from '../firebase.js';
import { doc, onSnapshot, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore'; import { doc, onSnapshot, setDoc, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
let selected = $state(new Set()); let selected = $state(new Set());
let totalCountries = $state(0); let totalCountries = $state(0);
@@ -20,29 +20,39 @@ export function initSelectionListener(uid) {
}); });
} }
const visitedRef = doc(db, 'visited', 'countries');
onSnapshot(visitedRef, (snap) => {
if (snap.exists()) {
selected = new Set(snap.data().ids ?? []);
}
});
function persist() {
setDoc(visitedRef, { ids: [...selected] });
}
export function toggle(id) { export function toggle(id) {
if (!_uid) return;
const was = selected.has(id); const was = selected.has(id);
const next = new Set(selected); const next = new Set(selected);
if (was) { if (was) next.delete(id);
next.delete(id); else next.add(id);
} else {
next.add(id);
}
selected = next; selected = next;
persist();
if (_uid) {
const userRef = doc(db, 'users', _uid); const userRef = doc(db, 'users', _uid);
if (was) { if (was) updateDoc(userRef, { visitedCountries: arrayRemove(id) });
updateDoc(userRef, { visitedCountries: arrayRemove(id) }); else updateDoc(userRef, { visitedCountries: arrayUnion(id) });
} else {
updateDoc(userRef, { visitedCountries: arrayUnion(id) });
} }
} }
export function clearAll() { export function clearAll() {
if (!_uid) return;
selected = new Set(); selected = new Set();
persist();
if (_uid) {
const userRef = doc(db, 'users', _uid); const userRef = doc(db, 'users', _uid);
updateDoc(userRef, { visitedCountries: [] }); updateDoc(userRef, { visitedCountries: [] });
}
} }
export function getSelected() { export function getSelected() {

View File

@@ -1,25 +1,74 @@
export const countryCodeMap = { import { feature } from 'topojson-client';
'Argentina': 'AR', 'Australia': 'AU', 'Austria': 'AT', import worldData from 'world-atlas/countries-50m.json';
'Belgium': 'BE', 'Brazil': 'BR',
'Canada': 'CA', 'Chile': 'CL', 'China': 'CN', 'Croatia': 'HR', // Full name → alpha-2 map covering all world-atlas country names.
'Czech Republic': 'CZ', 'Denmark': 'DK', 'Egypt': 'EG', const nameToAlpha2 = {
'Finland': 'FI', 'France': 'FR', 'Germany': 'DE', 'Greece': 'GR', 'Afghanistan':'AF','Albania':'AL','Algeria':'DZ','American Samoa':'AS',
'Hungary': 'HU', 'India': 'IN', 'Indonesia': 'ID', 'Italy': 'IT', 'Andorra':'AD','Angola':'AO','Anguilla':'AI','Antigua and Barb.':'AG',
'Japan': 'JP', 'Kenya': 'KE', 'Argentina':'AR','Armenia':'AM','Aruba':'AW','Ashmore and Cartier Is.':'AU',
'Malaysia': 'MY', 'Mexico': 'MX', 'Morocco': 'MA', 'Australia':'AU','Austria':'AT','Azerbaijan':'AZ','Bahamas':'BS',
'Netherlands': 'NL', 'New Zealand': 'NZ', 'Norway': 'NO', 'Bahrain':'BH','Bangladesh':'BD','Barbados':'BB','Belarus':'BY',
'Peru': 'PE', 'Poland': 'PL', 'Portugal': 'PT', 'Belgium':'BE','Belize':'BZ','Benin':'BJ','Bermuda':'BM','Bhutan':'BT',
'Singapore': 'SG', 'South Africa': 'ZA', 'South Korea': 'KR', 'Bolivia':'BO','Bosnia and Herz.':'BA','Botswana':'BW',
'Spain': 'ES', 'Sweden': 'SE', 'Switzerland': 'CH', 'Br. Indian Ocean Ter.':'IO','Brazil':'BR','British Virgin Is.':'VG',
'Taiwan': 'TW', 'Thailand': 'TH', 'Turkey': 'TR', 'Brunei':'BN','Bulgaria':'BG','Burkina Faso':'BF','Burundi':'BI',
'UK': 'GB', 'USA': 'US', 'Vietnam': 'VN', 'Cabo Verde':'CV','Cambodia':'KH','Cameroon':'CM','Canada':'CA',
'Cayman Is.':'KY','Central African Rep.':'CF','Chad':'TD','Chile':'CL',
'China':'CN','Colombia':'CO','Comoros':'KM','Congo':'CG','Cook Is.':'CK',
'Costa Rica':'CR','Croatia':'HR','Cuba':'CU','Curaçao':'CW','Cyprus':'CY',
'Czechia':'CZ',"Côte d'Ivoire":'CI','Dem. Rep. Congo':'CD','Denmark':'DK',
'Djibouti':'DJ','Dominica':'DM','Dominican Rep.':'DO','Ecuador':'EC',
'Egypt':'EG','El Salvador':'SV','Eq. Guinea':'GQ','Eritrea':'ER',
'Estonia':'EE','Ethiopia':'ET','Faeroe Is.':'FO','Falkland Is.':'FK',
'Fiji':'FJ','Finland':'FI','Fr. Polynesia':'PF','France':'FR','Gabon':'GA',
'Gambia':'GM','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR',
'Greenland':'GL','Grenada':'GD','Guam':'GU','Guatemala':'GT',
'Guernsey':'GG','Guinea':'GN','Guinea-Bissau':'GW','Guyana':'GY',
'Haiti':'HT','Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS',
'India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ','Ireland':'IE',
'Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP',
'Jersey':'JE','Jordan':'JO','Kazakhstan':'KZ','Kenya':'KE','Kiribati':'KI',
'Kosovo':'XK','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV',
'Lebanon':'LB','Lesotho':'LS','Liberia':'LR','Libya':'LY',
'Liechtenstein':'LI','Lithuania':'LT','Luxembourg':'LU','Macao':'MO',
'Macedonia':'MK','Madagascar':'MG','Malawi':'MW','Malaysia':'MY',
'Maldives':'MV','Mali':'ML','Malta':'MT','Marshall Is.':'MH',
'Mauritania':'MR','Mauritius':'MU','Mexico':'MX','Micronesia':'FM',
'Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME',
'Montserrat':'MS','Morocco':'MA','Mozambique':'MZ','Myanmar':'MM',
'N. Cyprus':'CY','N. Mariana Is.':'MP','Namibia':'NA','Nauru':'NR',
'Nepal':'NP','Netherlands':'NL','New Caledonia':'NC','New Zealand':'NZ',
'Nicaragua':'NI','Niger':'NE','Nigeria':'NG','Niue':'NU',
'Norfolk Island':'NF','North Korea':'KP','Norway':'NO','Oman':'OM',
'Pakistan':'PK','Palau':'PW','Palestine':'PS','Panama':'PA',
'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH',
'Pitcairn Is.':'PN','Poland':'PL','Portugal':'PT','Puerto Rico':'PR',
'Qatar':'QA','Romania':'RO','Russia':'RU','Rwanda':'RW','S. Sudan':'SS',
'Saint Helena':'SH','Saint Lucia':'LC','Samoa':'WS','San Marino':'SM',
'Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Seychelles':'SC',
'Sierra Leone':'SL','Singapore':'SG','Sint Maarten':'SX','Slovakia':'SK',
'Slovenia':'SI','Solomon Is.':'SB','Somalia':'SO','South Africa':'ZA',
'South Korea':'KR','Spain':'ES','Sri Lanka':'LK','St-Barthélemy':'BL',
'St-Martin':'MF','St. Kitts and Nevis':'KN','St. Pierre and Miquelon':'PM',
'St. Vin. and Gren.':'VC','Sudan':'SD','Suriname':'SR','Sweden':'SE',
'Switzerland':'CH','Syria':'SY','São Tomé and Principe':'ST','Taiwan':'TW',
'Tajikistan':'TJ','Tanzania':'TZ','Thailand':'TH','Timor-Leste':'TL',
'Togo':'TG','Tonga':'TO','Trinidad and Tobago':'TT','Tunisia':'TN',
'Turkey':'TR','Turkmenistan':'TM','Turks and Caicos Is.':'TC',
'U.S. Virgin Is.':'VI','Uganda':'UG','Ukraine':'UA',
'United Arab Emirates':'AE','United Kingdom':'GB',
'United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ',
'Vanuatu':'VU','Vatican':'VA','Venezuela':'VE','Vietnam':'VN',
'W. Sahara':'EH','Yemen':'YE','Zambia':'ZM','Zimbabwe':'ZW',
'eSwatini':'SZ','Åland':'AX',
}; };
export const countryNames = Object.keys(countryCodeMap).sort(); export const countryNames = feature(worldData, worldData.objects.countries)
.features.map(f => f.properties?.name).filter(Boolean).sort();
/** @param {string} country */ /** @param {string} country */
export function flagEmoji(country) { export function flagEmoji(country) {
const code = countryCodeMap[country]; const code = nameToAlpha2[country];
if (!code) return ''; if (!code) return '';
return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join(''); return [...code].map(c => String.fromCodePoint(0x1F1E6 - 65 + c.charCodeAt(0))).join('');
} }

View File

@@ -1,4 +1,8 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { db } from '../firebase.js';
import {
collection, onSnapshot, addDoc, updateDoc, deleteDoc, doc, serverTimestamp
} from 'firebase/firestore';
/** /**
* @typedef {{ * @typedef {{
@@ -14,110 +18,28 @@ import { writable } from 'svelte/store';
* }} JournalEntry * }} JournalEntry
*/ */
/** @type {JournalEntry[]} */ export const journals = writable(/** @type {JournalEntry[]} */([]));
const mockEntries = [ export const journalsLoading = writable(true);
{
id: '1',
title: 'First Day in Tokyo',
date: '2024-03-15',
location: { country: 'Japan', cities: ['Tokyo'] },
photos: [
'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=600&q=80',
'https://images.unsplash.com/photo-1513407030348-c983a97b98d8?w=600&q=80',
'https://images.unsplash.com/photo-1490806843957-31f4c9a91c65?w=600&q=80',
],
transport: 'flight',
tripType: 'solo',
days: 5,
memo: 'Got completely lost in Shinjuku — stumbled into a tiny ramen shop with no English menu. The chashu just melted. Worth every wrong turn.',
},
{
id: '2',
title: 'Arashiyama Bamboo Grove',
date: '2024-03-18',
location: { country: 'Japan', cities: ['Kyoto'] },
photos: [
'https://images.unsplash.com/photo-1528360983277-13d401cdc186?w=600&q=80',
'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=600&q=80',
],
transport: 'train',
tripType: 'friends',
days: 3,
memo: 'Arrived at 6am before the crowds. Just me and the wind moving through the bamboo. One of those moments you keep coming back to.',
},
{
id: '3',
title: 'Sunset on Montmartre',
date: '2024-06-02',
location: { country: 'France', cities: ['Paris'] },
photos: [
'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=600&q=80',
'https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=600&q=80',
'https://images.unsplash.com/photo-1511739001486-6bfe10ce785f?w=600&q=80',
],
transport: 'flight',
tripType: 'solo',
days: 7,
memo: 'Watched the whole city turn orange from the steps of Sacré-Cœur. A street musician was playing La Vie en Rose. Cliché, perfect.',
},
{
id: '4',
title: 'Inside La Sagrada Família',
date: '2024-06-10',
location: { country: 'Spain', cities: ['Barcelona'] },
photos: [
'https://images.unsplash.com/photo-1523531294919-4bcd7c65e216?w=600&q=80',
'https://images.unsplash.com/photo-1583422409516-2895a77efded?w=600&q=80',
],
transport: 'flight',
tripType: 'friends',
days: 4,
memo: 'Nothing prepares you for the light inside. The stained glass turns the whole nave into a kaleidoscope. Gaudí was building a forest.',
},
{
id: '5',
title: 'Central Park in Fall',
date: '2023-10-20',
location: { country: 'USA', cities: ['New York'] },
photos: [
'https://images.unsplash.com/photo-1534430480872-3498386e7856?w=600&q=80',
'https://images.unsplash.com/photo-1485871981521-5b1fd3805345?w=600&q=80',
'https://images.unsplash.com/photo-1522083165195-3424ed129620?w=600&q=80',
],
transport: 'car',
tripType: 'friends',
days: 6,
memo: 'Peak foliage. Joggers, picnics, a guy playing saxophone near Bethesda Fountain. Hard to believe a city this big wraps around this much quiet.',
},
{
id: '6',
title: 'Wat Pho Reclining Buddha',
date: '2024-01-08',
location: { country: 'Thailand', cities: ['Bangkok'] },
photos: [
'https://images.unsplash.com/photo-1563492065599-3520f775eeed?w=600&q=80',
'https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=600&q=80',
],
transport: 'ship',
tripType: 'solo',
days: 2,
memo: 'Stood in front of the 45m golden Buddha for a long time. The mother-of-pearl inlay on the soles of the feet is impossibly detailed.',
},
];
export const journals = writable(mockEntries); const entriesRef = collection(db, 'entries');
onSnapshot(entriesRef, (snap) => {
journals.set(snap.docs.map(d => ({ id: d.id, ...d.data() })));
journalsLoading.set(false);
});
/** @param {Omit<JournalEntry, 'id'>} entry */ /** @param {Omit<JournalEntry, 'id'>} entry */
export function addJournal(entry) { export async function addJournal(entry) {
journals.update((entries) => [...entries, { ...entry, id: crypto.randomUUID() }]); await addDoc(entriesRef, { ...entry, createdAt: serverTimestamp() });
} }
/** @param {string} id */ /** @param {string} id */
export function removeJournal(id) { export async function removeJournal(id) {
journals.update((entries) => entries.filter((e) => e.id !== id)); await deleteDoc(doc(db, 'entries', id));
} }
/** @param {JournalEntry} updated */ /** @param {JournalEntry} updated */
export function updateJournal(updated) { export async function updateJournal(updated) {
journals.update((entries) => entries.map((e) => e.id === updated.id ? updated : e)); const { id, ...data } = updated;
await updateDoc(doc(db, 'entries', id), data);
} }

View File

@@ -4,7 +4,7 @@
<div class="overlay" role="dialog" aria-modal="true"> <div class="overlay" role="dialog" aria-modal="true">
<div class="dialog"> <div class="dialog">
<h2 class="title">Delete entry?</h2> <h2 class="title">Delete trip?</h2>
<p class="body"> <p class="body">
<strong>{entry.location.cities.join(', ')}, {entry.location.country}</strong>{entry.date.slice(0, 4)} will be permanently removed. <strong>{entry.location.cities.join(', ')}, {entry.location.country}</strong>{entry.date.slice(0, 4)} will be permanently removed.
</p> </p>

View File

@@ -18,11 +18,19 @@
let cityInput = $state(''); let cityInput = $state('');
let country = $state(entry?.location.country ?? initialCountry); let country = $state(entry?.location.country ?? initialCountry);
let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10)); let date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
let days = $state(String(entry?.days ?? 1)); let days = $state(String(entry?.days ?? ''));
let tripType = $state(entry?.tripType ?? 'solo'); let tripType = $state(entry?.tripType ?? '');
let photos = $state([...(entry?.photos ?? [])]); let photos = $state([...(entry?.photos ?? [])]);
let memo = $state(entry?.memo ?? ''); let memo = $state(entry?.memo ?? '');
let transport = $state(entry?.transport ?? 'flight'); let transport = $state(entry?.transport ?? '');
let errors = $state({
country: '', cities: '', date: '', days: '', tripType: '', transport: ''
});
function clearErrors() {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
}
const transportOptions = [ const transportOptions = [
{ value: 'flight', label: '✈ Flight' }, { value: 'flight', label: '✈ Flight' },
@@ -49,8 +57,11 @@
} }
} }
// Suggest cities — when a country is selected show only cities from that country.
let cityOptions = $derived( let cityOptions = $derived(
[...new Set(get(journals).flatMap(e => e.location.cities))].sort() 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) { function addCity(val) {
@@ -65,9 +76,20 @@
cities = cities.filter(x => x !== c); cities = cities.filter(x => x !== c);
} }
function save() { async function save() {
clearErrors();
let hasError = false;
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
if (!date) { errors.date = 'Date is required.'; hasError = true; }
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
if (hasError) return;
try {
if (isNew) { if (isNew) {
addJournal({ await addJournal({
title: `${cities.join(', ')}, ${country}`, title: `${cities.join(', ')}, ${country}`,
date, date,
days: Number(days), days: Number(days),
@@ -78,7 +100,7 @@
location: { cities, country }, location: { cities, country },
}); });
} else { } else {
updateJournal({ await updateJournal({
...entry, ...entry,
date, date,
days: Number(days), days: Number(days),
@@ -90,6 +112,9 @@
}); });
} }
onBack(); onBack();
} catch (err) {
showToast('Failed to save. Please try again.');
}
} }
</script> </script>
@@ -104,7 +129,7 @@
Back Back
</button> </button>
</div> </div>
<span class="topbar-title">{isNew ? 'New entry' : 'Edit'}</span> <span class="topbar-title">{isNew ? 'New trip' : 'Edit'}</span>
<div class="topbar-right"> <div class="topbar-right">
<button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button> <button class="topbar-btn topbar-btn--save" onclick={save}>Save changes</button>
</div> </div>
@@ -117,12 +142,14 @@
<div class="field"> <div class="field">
<label class="label" for="edit-country">Country <span class="req">*</span></label> <label class="label" for="edit-country">Country <span class="req">*</span></label>
<SearchInput id="edit-country" bind:value={country} options={countryNames} required /> <SearchInput id="edit-country" bind:value={country} options={countryNames} required />
{#if errors.country}<span class="field-error">{errors.country}</span>{/if}
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="edit-city">Cities <span class="req">*</span></label> <label class="label" for="edit-city">Cities <span class="req">*</span></label>
<div class="city-input-row"> <div class="city-input-row">
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} /> <SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
</div> </div>
{#if errors.cities}<span class="field-error">{errors.cities}</span>{/if}
{#if cities.length > 0} {#if cities.length > 0}
<div class="city-tags"> <div class="city-tags">
{#each cities as c} {#each cities as c}
@@ -140,10 +167,12 @@
<div class="field"> <div class="field">
<label class="label" for="edit-date">Date <span class="req">*</span></label> <label class="label" for="edit-date">Date <span class="req">*</span></label>
<input id="edit-date" class="input" type="date" bind:value={date} required /> <input id="edit-date" class="input" type="date" bind:value={date} required />
{#if errors.date}<span class="field-error">{errors.date}</span>{/if}
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="edit-days">Days <span class="req">*</span></label> <label class="label" for="edit-days">Days <span class="req">*</span></label>
<input id="edit-days" class="input" type="number" min="1" bind:value={days} required /> <input id="edit-days" class="input" type="number" min="1" bind:value={days} required />
{#if errors.days}<span class="field-error">{errors.days}</span>{/if}
</div> </div>
</div> </div>
@@ -160,6 +189,7 @@
<input type="radio" name="tripType" value="family" bind:group={tripType} /> With family <input type="radio" name="tripType" value="family" bind:group={tripType} /> With family
</label> </label>
</div> </div>
{#if errors.tripType}<span class="field-error">{errors.tripType}</span>{/if}
</div> </div>
<div class="field"> <div class="field">
@@ -172,6 +202,7 @@
</label> </label>
{/each} {/each}
</div> </div>
{#if errors.transport}<span class="field-error">{errors.transport}</span>{/if}
</div> </div>
<PhotoEditor {photos} onchange={(p) => (photos = p)} /> <PhotoEditor {photos} onchange={(p) => (photos = p)} />
@@ -190,6 +221,12 @@
</div> </div>
<style> <style>
.field-error {
font-size: 11px;
color: #dc2626;
margin-top: 2px;
}
.edit-layout { .edit-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

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

View File

@@ -1,32 +1,35 @@
<script> <script>
import { storage } from '../firebase.js';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */ /** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
let { photos, onchange } = $props(); let { photos, onchange } = $props();
let fileInput; let fileInput;
let uploading = $state(false);
function remove(index) { function remove(index) {
const next = photos.filter((_, i) => i !== index); onchange(photos.filter((_, i) => i !== index));
onchange(next);
} }
async function addFiles(e) { async function addFiles(e) {
const files = Array.from(e.currentTarget.files ?? []); const files = Array.from(e.currentTarget.files ?? []);
if (!files.length) return; if (!files.length) return;
uploading = true;
const dataUrls = await Promise.all(files.map(fileToDataUrl)); try {
onchange([...photos, ...dataUrls]); const urls = await Promise.all(files.map(uploadPhoto));
onchange([...photos, ...urls]);
// reset so the same file can be picked again } finally {
uploading = false;
e.currentTarget.value = ''; e.currentTarget.value = '';
} }
}
/** @param {File} file */ /** @param {File} file */
function fileToDataUrl(file) { async function uploadPhoto(file) {
return new Promise((resolve) => { const storageRef = ref(storage, `photos/${crypto.randomUUID()}`);
const reader = new FileReader(); await uploadBytes(storageRef, file);
reader.onload = (e) => resolve(/** @type {string} */ (e.target.result)); return getDownloadURL(storageRef);
reader.readAsDataURL(file);
});
} }
</script> </script>
@@ -37,13 +40,13 @@
</div> </div>
{#if photos.length === 0} {#if photos.length === 0}
<button type="button" class="empty-zone" onclick={() => fileInput.click()}> <button type="button" class="empty-zone" onclick={() => fileInput.click()} disabled={uploading}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="3" y="3" width="18" height="18" rx="3"/> <rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8.5" cy="8.5" r="1.5"/> <circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/> <path d="M21 15l-5-5L5 21"/>
</svg> </svg>
<span>Click to add photos</span> <span>{uploading ? 'Uploading…' : 'Click to add photos'}</span>
</button> </button>
{:else} {:else}
<div class="grid"> <div class="grid">
@@ -58,7 +61,7 @@
</div> </div>
{/each} {/each}
<button type="button" class="add-cell" onclick={() => fileInput.click()}> <button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <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"/> <path d="M12 5v14M5 12h14"/>
</svg> </svg>

View File

@@ -5,6 +5,7 @@
import TimelineCard from './TimelineCard.svelte'; import TimelineCard from './TimelineCard.svelte';
import JournalDetail from './JournalDetail.svelte'; import JournalDetail from './JournalDetail.svelte';
import EditForm from './EditForm.svelte'; import EditForm from './EditForm.svelte';
import NewEntryForm from './NewEntryForm.svelte';
import ShareCard from './ShareCard.svelte'; import ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte'; import SharePreview from './SharePreview.svelte';
@@ -54,7 +55,7 @@
{#if view === 'new'} {#if view === 'new'}
<div class="detail-scroll"> <div class="detail-scroll">
<EditForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} /> <NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} />
</div> </div>
{:else if view === 'edit' && selected} {:else if view === 'edit' && selected}
<div class="detail-scroll"> <div class="detail-scroll">
@@ -70,19 +71,23 @@
</div> </div>
{:else} {:else}
<div class="right-panel"> <div class="right-panel">
<div class="two-col"> <div class="center-col">
<!-- Timeline column -->
<div class="timeline-col">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">My Journey</h1> <h1 class="page-title">My Journey</h1>
<button class="new-btn" onclick={() => { view = 'new'; }}> <button class="new-btn" onclick={() => { view = 'new'; }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/> <path d="M12 5v14M5 12h14"/>
</svg> </svg>
New entry Add trip
</button> </button>
</div> </div>
{#if sortedEntries.length > 0}
<div class="share-row">
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
</div>
{/if}
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} /> <TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0} {#if sortedEntries.length === 0}
@@ -110,17 +115,9 @@
{/if} {/if}
<footer class="page-footer"> <footer class="page-footer">
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'} {sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'}
</footer> </footer>
</div> </div>
<!-- Share preview column -->
{#if sortedEntries.length > 0}
<div class="share-col">
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
</div>
{/if}
</div>
</div> </div>
{/if} {/if}
@@ -148,30 +145,23 @@
background: var(--bg); background: var(--bg);
} }
/* ── Two-column layout ── */ /* ── Centered single column ── */
.two-col { .center-col {
display: grid; max-width: 680px;
grid-template-columns: 1fr 240px;
gap: 48px;
max-width: 1020px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 48px 48px 80px; padding: 48px 48px 80px;
align-items: start;
box-sizing: border-box; box-sizing: border-box;
} }
.timeline-col { min-width: 0; } .share-row {
margin-bottom: 24px;
}
.share-col { padding-top: 60px; } @media (max-width: 760px) {
.center-col {
/* ── Responsive: narrow viewport ── */
@media (max-width: 860px) {
.two-col {
grid-template-columns: 1fr;
padding: 32px 24px 60px; padding: 32px 24px 60px;
} }
.share-col { padding-top: 0; }
} }
/* ── Detail view ── */ /* ── Detail view ── */

View File

@@ -1,46 +1,18 @@
<script> <script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js'; import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js'; import { getSelected } from '../layout/selection.svelte.js';
import worldData from 'world-atlas/countries-50m.json';
let collapsed = $state(false); let hoveredSeg = $state(null);
const continentColors = { const continentColors = {
'Europe': '#3b82f6', 'Europe': '#6366f1',
'Asia': '#ef4444', 'Asia': '#f43f5e',
'Africa': '#f97316', 'Africa': '#fb923c',
'N. America': '#22c55e', 'N. America': '#06b6d4',
'S. America': '#eab308', 'S. America': '#f59e0b',
'Oceania': '#a855f7' 'Oceania': '#8b5cf6'
}; };
const countryNameById = $derived.by(() => {
const map = { XK: 'Kosovo' };
for (const g of worldData.objects.countries.geometries) {
map[g.id] = g.properties?.name || g.id;
}
return map;
});
let visitedCountries = $derived(
[...getSelected()].map(id => countryNameById[id]).filter(Boolean).sort()
);
let visitedByContinent = $derived.by(() => {
const map = {};
for (const id of getSelected()) {
const cont = getContinent(id);
if (cont) {
if (!map[cont]) map[cont] = [];
map[cont].push(countryNameById[id] || id);
}
}
for (const cont of Object.keys(map)) {
map[cont].sort();
}
return map;
});
let counts = $derived.by(() => { let counts = $derived.by(() => {
const c = {}; const c = {};
for (const cont of CONTINENTS) c[cont] = 0; for (const cont of CONTINENTS) c[cont] = 0;
@@ -64,11 +36,9 @@
if (angle > 0) { if (angle > 0) {
const startDeg = deg; const startDeg = deg;
const endDeg = deg + angle; const endDeg = deg + angle;
const midDeg = (startDeg + endDeg) / 2;
const rad = (midDeg - 90) * Math.PI / 180;
const sr = (startDeg - 90) * Math.PI / 180; const sr = (startDeg - 90) * Math.PI / 180;
const er = (endDeg - 90) * Math.PI / 180; const er = (endDeg - 90) * Math.PI / 180;
const cx = 90, cy = 90, outerR = 65, innerR = 30; const cx = 50, cy = 50, outerR = 44, innerR = 22;
const x1 = cx + outerR * Math.cos(sr); const x1 = cx + outerR * Math.cos(sr);
const y1 = cy + outerR * Math.sin(sr); const y1 = cy + outerR * Math.sin(sr);
const x2 = cx + outerR * Math.cos(er); const x2 = cx + outerR * Math.cos(er);
@@ -79,9 +49,7 @@
const y4 = cy + innerR * Math.sin(sr); const y4 = cy + innerR * Math.sin(sr);
const largeArc = angle > 180 ? 1 : 0; const largeArc = angle > 180 ? 1 : 0;
const path = `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${largeArc} 0 ${x4} ${y4} Z`; const path = `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${largeArc} 0 ${x4} ${y4} Z`;
const lx = cx + 82 * Math.cos(rad); segs.push({ cont, color: continentColors[cont], path, angle });
const ly = cy + 82 * Math.sin(rad);
segs.push({ cont, color: continentColors[cont], path, lx, ly, angle });
deg += angle; deg += angle;
} }
} }
@@ -89,292 +57,199 @@
}); });
</script> </script>
<div class="panel" class:collapsed> <div class="card">
<button class="collapse-btn" onclick={() => collapsed = !collapsed} data-tip={collapsed ? 'see statistics' : 'close statistics'}> <!-- count -->
{collapsed ? '◀' : '▶'} <div class="stat-block">
</button> <span class="big-num">{total}</span>
<span class="stat-sub">countries visited</span>
{#if !collapsed}
<div class="panel-content">
<h2 class="headline">your statistics</h2>
<span class="bar-label">visited countries</span>
<div class="total-bar-wrap">
<div class="total-bar-bg">
<div class="total-bar-fill" style="width: {pct}%"></div>
</div>
<span class="total-bar-text">{total} / {grandTotal}</span>
</div> </div>
<div class="divider"></div> <div class="vdivider"></div>
<span class="bar-label">by continent</span> <!-- world % -->
{#each CONTINENTS as continent} <div class="stat-block">
{@const contTotal = continentTotals[continent]} <span class="big-num accent">{pct}%</span>
<div class="row tooltip-wrap"> <span class="stat-sub">of the world</span>
<span class="dot" style="background: {continentColors[continent]}"></span>
<span class="label">{continent}</span>
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
{#if visitedByContinent[continent]?.length > 0}
<div class="tooltip-list">
{#each visitedByContinent[continent].slice(0, 10) as country}
<span class="tooltip-item">{country}</span>
{/each}
{#if visitedByContinent[continent].length > 10}
<span class="tooltip-item tooltip-more">...</span>
{/if}
</div> </div>
{/if}
</div>
{/each}
<div class="donut-wrap"> <div class="vdivider"></div>
<!-- donut -->
<div class="donut-block">
<svg viewBox="0 0 100 100" class="donut-svg">
{#if segments.length > 0} {#if segments.length > 0}
<svg viewBox="0 0 180 180" class="donut-svg">
{#each segments as seg} {#each segments as seg}
<g class="seg-group"> <g class="seg-group"
onmouseenter={() => hoveredSeg = seg}
onmouseleave={() => hoveredSeg = null}>
<path d={seg.path} fill={seg.color} /> <path d={seg.path} fill={seg.color} />
<text x={seg.lx} y={seg.ly} text-anchor="middle" dominant-baseline="middle" class="donut-label" style="font-size: {seg.angle < 20 ? 12 : 15}px">{seg.cont}</text>
</g> </g>
{/each} {/each}
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" /> <circle cx="50" cy="50" r="22" fill="#fff" />
</svg>
{:else} {:else}
<svg viewBox="0 0 180 180" class="donut-svg"> <circle cx="50" cy="50" r="44" fill="#f1f5f9" />
<circle cx="90" cy="90" r="65" fill="var(--border)" /> <circle cx="50" cy="50" r="22" fill="#fff" />
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" /> {/if}
</svg> </svg>
<div class="donut-info">
<span class="section-label">by continent</span>
{#if hoveredSeg}
<div class="tooltip" style="--dot:{hoveredSeg.color}">
<span class="tt-name">{hoveredSeg.cont}</span>
<span class="tt-val">{counts[hoveredSeg.cont]} / {continentTotals[hoveredSeg.cont]}</span>
</div>
{:else}
<span class="hint">hover a slice</span>
{/if} {/if}
</div> </div>
<div class="divider"></div>
<div class="disclaimer">Contains all UN countries, Kosovo, Hong Kong and Taiwan</div>
</div> </div>
{/if}
<div class="vdivider"></div>
<!-- progress bar -->
<div class="bar-block">
<span class="section-label" style="margin-bottom:6px">world coverage</span>
<div class="bar-bg">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="disclaimer">All UN countries · Kosovo · HK · Taiwan</span>
</div>
</div> </div>
<style> <style>
.panel { .card {
flex: 0 0 min(360px, 25vw); position: absolute;
background: var(--bg-raised); top: 16px;
border-left: 1px solid var(--border); left: 50%;
transform: translateX(-50%);
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
border: 1px solid rgba(0,0,0,0.06);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
gap: 0;
padding: 0 4px;
height: 110px;
z-index: 10;
font-family: var(--sans); font-family: var(--sans);
transition: flex-basis 0.25s ease; white-space: nowrap;
} }
.panel.collapsed { .stat-block {
flex: 0 0 28px; display: flex;
border-left: none; flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 36px;
} }
.panel-content { .big-num {
flex: 1; font-size: 40px;
padding: 24px 28px; font-weight: 300;
overflow-y: auto; letter-spacing: -2px;
min-width: 0; color: var(--text-h);
}
.collapse-btn {
flex: 0 0 auto;
align-self: flex-start;
background: var(--accent-bg);
border: none;
border-radius: 0 8px 8px 0;
padding: 14px 5px;
cursor: pointer;
font-size: 16px;
line-height: 1; line-height: 1;
color: var(--accent);
transition: background 0.15s ease, padding 0.15s ease;
margin-top: 24px;
position: relative;
} }
.big-num.accent { color: var(--accent); }
.collapse-btn:hover { .stat-sub {
background: var(--lavender-bg);
padding-right: 8px;
}
.collapse-btn::after {
content: attr(data-tip);
position: absolute;
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
background: var(--text-h);
color: var(--bg-raised);
font-family: var(--sans);
font-size: 12px; font-size: 12px;
font-weight: 300; font-weight: 300;
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.collapse-btn:hover::after {
opacity: 1;
}
.headline {
font-family: var(--heading);
font-size: var(--text-sm);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin: 0 0 20px 0;
}
.bar-label {
font-family: var(--sans);
font-size: var(--text-xs);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-sub); color: var(--text-sub);
display: block; letter-spacing: 0.03em;
margin-bottom: 8px;
} }
.total-bar-wrap { .vdivider {
display: flex; width: 1px;
align-items: center; height: 56px;
gap: 12px;
margin-bottom: 4px;
}
.total-bar-bg {
flex: 1;
height: 18px;
background: var(--accent-bg);
border-radius: 10px;
overflow: hidden;
}
.total-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dark), var(--lavender));
border-radius: 10px;
transition: width 0.3s ease;
min-width: 0;
}
.total-bar-text {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
white-space: nowrap;
}
.divider {
height: 1px;
background: var(--border); background: var(--border);
margin: 16px 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
.label { /* donut */
flex: 1; .donut-block {
font-size: var(--text-sm);
font-weight: 300;
color: var(--text);
}
.value {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
}
.total {
font-weight: 400;
color: var(--text-sub);
font-size: var(--text-xs);
}
.donut-wrap {
display: flex; display: flex;
justify-content: center; flex-direction: row;
margin: 20px 0; align-items: center;
gap: 14px;
padding: 0 28px;
} }
.donut-svg { .donut-svg {
width: 160px; width: 72px;
height: 160px; height: 72px;
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15)); flex-shrink: 0;
}
.seg-group { cursor: pointer; }
.seg-group:hover path { opacity: 0.8; }
.donut-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 130px;
} }
.donut-label { .tooltip {
fill: var(--text-h); display: flex;
font-family: var(--sans); align-items: center;
font-weight: 300; gap: 7px;
pointer-events: none; font-size: 13px;
opacity: 0;
transition: opacity 0.15s ease;
} }
.tooltip::before {
.seg-group:hover .donut-label { content: '';
opacity: 1; width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dot);
flex-shrink: 0;
} }
.tt-name { font-weight: 400; color: var(--text-h); }
.tt-val { font-weight: 300; color: var(--text-sub); }
.tooltip-wrap { .section-label {
position: relative; font-size: 10px;
} font-weight: 500;
letter-spacing: 0.14em;
.tooltip-list { text-transform: uppercase;
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
background: var(--text-h);
color: var(--bg-raised);
font-family: var(--sans);
font-size: 12px;
line-height: 1.5;
padding: 8px 12px;
border-radius: 8px;
box-shadow: var(--shadow);
z-index: 20;
white-space: nowrap;
min-width: 120px;
}
.tooltip-wrap:hover .tooltip-list {
display: block;
}
.tooltip-item {
display: block;
padding: 2px 0;
}
.tooltip-item + .tooltip-item {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.disclaimer {
font-size: var(--text-xs);
color: var(--text-sub); color: var(--text-sub);
line-height: 1.5; }
text-align: center;
.hint {
font-size: 12px;
color: var(--text-sub);
opacity: 0.45;
}
/* bar */
.bar-block {
display: flex;
flex-direction: column;
padding: 0 28px;
gap: 0;
min-width: 160px;
}
.bar-bg {
width: 100%;
height: 5px;
background: var(--bg-subtle);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #a78bfa);
border-radius: 4px;
transition: width 0.3s ease;
min-width: 0;
}
.disclaimer {
font-size: 11px;
color: var(--text-sub);
opacity: 0.5;
letter-spacing: 0.02em;
} }
</style> </style>