3 Commits

Author SHA1 Message Date
haerikimmm
06e5fe5593 feat: add transport image icons and expand country list to match world map 2026-06-15 19:54:45 +09:00
haerikimmm
dd7932ea4e 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>
2026-06-15 19:54:45 +09:00
haerikimmm
40e75f30e8 changed to centered layout 2026-06-15 19:50:13 +09:00
28 changed files with 2003 additions and 1087 deletions

View File

@@ -3,7 +3,7 @@
"configurations": [ "configurations": [
{ {
"name": "Map-Jurnal", "name": "Map-Jurnal",
"cwd": "/Users/haerikim/Desktop/Map-Jurnal", "cwd": "/Users/haerikim/Desktop/SP Map Journal/Map-Jurnal",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 5173, "port": 5173,

1347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.55.5", "svelte": "^5.55.5",
"vite": "^8.0.12" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"d3": "^7.9.0", "d3": "^7.9.0",

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 {

BIN
src/assets/airplane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/assets/bus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
src/assets/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
src/assets/ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
src/assets/train.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
src/assets/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

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

@@ -3,7 +3,7 @@
* Searchable combobox input. * Searchable combobox input.
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }} * @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
*/ */
let { id, value = $bindable(), options, placeholder = '', required = false } = $props(); let { id, value = $bindable(), options, placeholder = '', required = false, onselect } = $props();
let query = $state(value); let query = $state(value);
let open = $state(false); let open = $state(false);
@@ -20,6 +20,7 @@
value = opt; value = opt;
open = false; open = false;
focused = -1; focused = -1;
onselect?.(opt);
} }
function onInput(e) { function onInput(e) {
@@ -33,7 +34,7 @@
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; } if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); } if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); } else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); }
else if (e.key === 'Enter' && focused >= 0) { e.preventDefault(); select(filtered[focused]); } else if (e.key === 'Enter') { e.preventDefault(); if (focused >= 0) { select(filtered[focused]); } else if (query.trim()) { select(query.trim()); } }
else if (e.key === 'Escape') { open = false; focused = -1; } else if (e.key === 'Escape') { open = false; focused = -1; }
} }

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,123 +1,45 @@
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 {{
* id: string, * id: string,
* title: string, * title: string,
* date: string, * date: string,
* location: { country: string, city: string }, * location: { country: string, cities: string[] },
* photos: string[], * photos: string[],
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk', * transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
* tripType: 'solo' | 'friends', * tripType: 'solo' | 'friends' | 'family',
* days: number, * days: number,
* memo: string * memo: string
* }} 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', city: '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', city: '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', city: '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', city: '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', city: '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', city: '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,9 +4,9 @@
<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.city}, {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>
<div class="actions"> <div class="actions">
<button class="btn btn-cancel" onclick={onCancel}>Cancel</button> <button class="btn btn-cancel" onclick={onCancel}>Cancel</button>

View File

@@ -14,14 +14,23 @@
let isNew = !entry; let isNew = !entry;
let city = $state(entry?.location.city ?? ''); let cities = $state([...(entry?.location.cities ?? [])]);
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' },
@@ -48,24 +57,50 @@
} }
} }
// Suggest cities — when a country is selected show only cities from that country.
let cityOptions = $derived( let cityOptions = $derived(
[...new Set(get(journals).map(e => e.location.city))].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 save() { function addCity(val) {
const trimmed = (val ?? cityInput).trim();
if (trimmed && !cities.includes(trimmed)) {
cities = [...cities, trimmed];
}
cityInput = '';
}
function removeCity(c) {
cities = cities.filter(x => x !== c);
}
async function save() {
clearErrors();
let hasError = false;
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
if (!date) { errors.date = 'Date is required.'; hasError = true; }
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
if (hasError) return;
try {
if (isNew) { if (isNew) {
addJournal({ await addJournal({
title: `${city}, ${country}`, title: `${cities.join(', ')}, ${country}`,
date, date,
days: Number(days), days: Number(days),
tripType, tripType,
memo, memo,
photos, photos,
transport, transport,
location: { city, country }, location: { cities, country },
}); });
} else { } else {
updateJournal({ await updateJournal({
...entry, ...entry,
date, date,
days: Number(days), days: Number(days),
@@ -73,10 +108,13 @@
transport, transport,
memo, memo,
photos, photos,
location: { city, country }, location: { cities, country },
}); });
} }
onBack(); onBack();
} catch (err) {
showToast('Failed to save. Please try again.');
}
} }
</script> </script>
@@ -91,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>
@@ -104,10 +142,24 @@
<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">City <span class="req">*</span></label> <label class="label" for="edit-city">Cities <span class="req">*</span></label>
<SearchInput id="edit-city" bind:value={city} options={cityOptions} required /> <div class="city-input-row">
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
</div>
{#if errors.cities}<span class="field-error">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="city-tags">
{#each cities as c}
<span class="city-tag">
{c}
<button type="button" class="city-tag-remove" onclick={() => removeCity(c)}>×</button>
</span>
{/each}
</div>
{/if}
</div> </div>
</div> </div>
@@ -115,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>
@@ -131,7 +185,11 @@
<label class="toggle-opt" class:active={tripType === 'friends'}> <label class="toggle-opt" class:active={tripType === 'friends'}>
<input type="radio" name="tripType" value="friends" bind:group={tripType} /> With friends <input type="radio" name="tripType" value="friends" bind:group={tripType} /> With friends
</label> </label>
<label class="toggle-opt" class:active={tripType === 'family'}>
<input type="radio" name="tripType" value="family" bind:group={tripType} /> With family
</label>
</div> </div>
{#if errors.tripType}<span class="field-error">{errors.tripType}</span>{/if}
</div> </div>
<div class="field"> <div class="field">
@@ -144,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)} />
@@ -162,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;
@@ -355,4 +420,39 @@
color: var(--accent); color: var(--accent);
} }
.city-input-row {
display: flex;
}
.city-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.city-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 300;
color: var(--accent);
background: var(--accent-bg);
border: 1px solid var(--accent-border);
border-radius: 20px;
padding: 3px 10px 3px 12px;
}
.city-tag-remove {
background: none;
border: none;
color: var(--accent);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0;
opacity: 0.6;
transition: opacity 0.15s;
}
.city-tag-remove:hover { opacity: 1; }
</style> </style>

View File

@@ -50,7 +50,7 @@
<span class="topbar-flag">{flagEmoji(entry.location.country)}</span> <span class="topbar-flag">{flagEmoji(entry.location.country)}</span>
<div class="topbar-place"> <div class="topbar-place">
<span class="topbar-city">{entry.location.city}</span> <span class="topbar-city">{entry.location.cities.join(', ')}</span>
<span class="topbar-country">{entry.location.country}</span> <span class="topbar-country">{entry.location.country}</span>
</div> </div>
</div> </div>
@@ -114,6 +114,8 @@
<p class="answer"> <p class="answer">
{#if entry.tripType === 'solo'} {#if entry.tripType === 'solo'}
Just me — solo trip Just me — solo trip
{:else if entry.tripType === 'family'}
With family
{:else} {:else}
With friends With friends
{/if} {/if}

View File

@@ -7,7 +7,7 @@
const totalDays = entries.reduce((s, e) => s + e.days, 0); const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))]; const countries = [...new Set(entries.map(e => e.location.country))];
const cities = [...new Set(entries.map(e => e.location.city))]; const cities = [...new Set(entries.flatMap(e => e.location.cities))];
const years = entries.map(e => new Date(e.date).getFullYear()); const years = entries.map(e => new Date(e.date).getFullYear());
const minYear = Math.min(...years); const minYear = Math.min(...years);

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

View File

@@ -1,55 +1,52 @@
<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>
<div class="photo-editor"> <div class="photo-editor">
<div class="label-row"> <div class="label-row">
<span class="label">Photos</span> <span class="label">Photos</span>
<button type="button" class="add-btn" onclick={() => fileInput.click()}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>
Add photos
</button>
<input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden /> <input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden />
</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">
@@ -64,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

@@ -41,7 +41,7 @@
const totalDays = entries.reduce((s, e) => s + e.days, 0); const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))]; const countries = [...new Set(entries.map(e => e.location.country))];
const cities = [...new Set(entries.map(e => e.location.city))]; const cities = [...new Set(entries.flatMap(e => e.location.cities))];
// Continent days // Continent days
const contDays = {}; const contDays = {};
@@ -68,6 +68,7 @@
// Solo vs friends // Solo vs friends
const soloCount = entries.filter(e => e.tripType === 'solo').length; const soloCount = entries.filter(e => e.tripType === 'solo').length;
const friendCount = entries.filter(e => e.tripType === 'friends').length; const friendCount = entries.filter(e => e.tripType === 'friends').length;
const familyCount = entries.filter(e => e.tripType === 'family').length;
// Most visited country // Most visited country
const countryCounts = {}; const countryCounts = {};
@@ -79,7 +80,7 @@
return { return {
totalDays, countries, cities, contDays, topContinent, totalDays, countries, cities, contDays, topContinent,
longest, flightHrs, soloCount, friendCount, longest, flightHrs, soloCount, friendCount, familyCount,
favCountry, spanMonths, yearStart, yearEnd, favCountry, spanMonths, yearStart, yearEnd,
}; };
}); });
@@ -175,7 +176,7 @@
{#if stats.longest} {#if stats.longest}
<div class="fact"> <div class="fact">
<span class="fact-icon">📍</span> <span class="fact-icon">📍</span>
<span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.city}</span> <span class="fact-text">Longest stay: <strong>{stats.longest.days} days</strong> in {stats.longest.location.cities.join(', ')}</span>
</div> </div>
{/if} {/if}
{#if stats.flightHrs > 0} {#if stats.flightHrs > 0}
@@ -192,7 +193,7 @@
{/if} {/if}
<div class="fact"> <div class="fact">
<span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</span> <span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</span>
<span class="fact-text">{stats.soloCount} solo · {stats.friendCount} with friends</span> <span class="fact-text">{stats.soloCount} solo{stats.friendCount > 0 ? ` · ${stats.friendCount} with friends` : ''}{stats.familyCount > 0 ? ` · ${stats.familyCount} with family` : ''}</span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,235 @@
<script>
/** @type {{ entries: import('../stores/journalStore.js').JournalEntry[], onClick: () => void }} */
let { entries, onClick } = $props();
const continentMap = {
'Japan':'Asia','South Korea':'Asia','China':'Asia','Thailand':'Asia','Vietnam':'Asia',
'Indonesia':'Asia','Malaysia':'Asia','Singapore':'Asia','India':'Asia','Taiwan':'Asia',
'Philippines':'Asia','Cambodia':'Asia','Nepal':'Asia',
'France':'Europe','Spain':'Europe','Italy':'Europe','Germany':'Europe','UK':'Europe',
'Netherlands':'Europe','Portugal':'Europe','Greece':'Europe','Sweden':'Europe',
'Norway':'Europe','Denmark':'Europe','Finland':'Europe','Switzerland':'Europe',
'Austria':'Europe','Belgium':'Europe','Poland':'Europe','Czech Republic':'Europe',
'Hungary':'Europe','Croatia':'Europe','Turkey':'Europe',
'USA':'N. America','Canada':'N. America','Mexico':'N. America',
'Brazil':'S. America','Argentina':'S. America','Chile':'S. America','Peru':'S. America',
'Australia':'Oceania','New Zealand':'Oceania',
'Morocco':'Africa','Egypt':'Africa','Kenya':'Africa','South Africa':'Africa',
};
const continentColors = {
'Asia':'#f87171','Europe':'#818cf8','N. America':'#4ade80',
'S. America':'#fbbf24','Africa':'#fb923c','Oceania':'#c084fc',
};
let stats = $derived.by(() => {
const totalDays = entries.reduce((s, e) => s + e.days, 0);
const countries = [...new Set(entries.map(e => e.location.country))];
const contDays = {};
for (const e of entries) {
const c = continentMap[e.location.country] ?? 'Other';
contDays[c] = (contDays[c] ?? 0) + e.days;
}
const top = Object.entries(contDays).sort((a, b) => b[1] - a[1]);
return { totalDays, countries, contDays, top, trips: entries.length };
});
</script>
<button class="preview-card" onclick={onClick} aria-label="Share your journey">
<div class="pc-bg"></div>
<div class="pc-grid-pattern"></div>
<div class="pc-header">
<span class="pc-brand">MAP JOURNAL</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pc-share-icon">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
</svg>
</div>
<div class="pc-hero">
<span class="pc-num">{stats.totalDays}</span>
<span class="pc-label">days traveled</span>
</div>
<div class="pc-row">
<div class="pc-stat">
<span class="pc-stat-num">{stats.countries.length}</span>
<span class="pc-stat-label">countries</span>
</div>
<div class="pc-stat">
<span class="pc-stat-num">{stats.trips}</span>
<span class="pc-stat-label">trips</span>
</div>
<div class="pc-stat">
<span class="pc-stat-num">{Object.keys(stats.contDays).length}</span>
<span class="pc-stat-label">continents</span>
</div>
</div>
{#if stats.top.length > 0}
<div class="pc-bar-wrap">
<div class="pc-bar">
{#each stats.top as [cont, days]}
<div class="pc-seg" style="flex:{days}; background:{continentColors[cont] ?? '#818cf8'}"></div>
{/each}
</div>
<div class="pc-bar-labels">
{#each stats.top.slice(0,3) as [cont, days]}
<span class="pc-bar-label" style="color:{continentColors[cont] ?? '#818cf8'}">{cont}</span>
{/each}
</div>
</div>
{/if}
<div class="pc-cta">Share your journey →</div>
</button>
<style>
.preview-card {
position: sticky;
top: 40px;
width: 100%;
background: #1a1630;
border-radius: 12px;
overflow: hidden;
color: #fff;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.06);
text-align: left;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
font-family: var(--sans);
}
.preview-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(124,58,237,0.12);
}
.pc-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 90% 0%, rgba(124,58,237,0.2) 0%, transparent 60%),
radial-gradient(ellipse 60% 60% at 0% 100%, rgba(99,102,241,0.1) 0%, transparent 60%);
pointer-events: none;
}
.pc-grid-pattern {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
}
.pc-header {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.pc-brand {
font-size: 8px;
font-weight: 500;
letter-spacing: 0.2em;
color: #a5b4fc;
}
.pc-share-icon { color: #a5b4fc; flex-shrink: 0; }
.pc-hero {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.pc-num {
font-size: 40px;
font-weight: 400;
line-height: 1;
letter-spacing: -1.5px;
color: #fff;
}
.pc-label {
font-size: 11px;
font-weight: 300;
color: #a5b4fc;
letter-spacing: 0.04em;
}
.pc-row {
display: flex;
gap: 0;
position: relative;
z-index: 1;
}
.pc-stat {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 12px;
border-right: 1px solid rgba(255,255,255,0.08);
}
.pc-stat:last-child { border-right: none; padding-right: 0; padding-left: 12px; }
.pc-stat:not(:first-child):not(:last-child) { padding-left: 12px; }
.pc-stat-num {
font-size: 16px;
font-weight: 400;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
}
.pc-stat-label {
font-size: 9px;
font-weight: 300;
color: rgba(255,255,255,0.4);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.pc-bar-wrap {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.pc-bar {
display: flex;
height: 4px;
border-radius: 2px;
overflow: hidden;
gap: 2px;
}
.pc-seg { border-radius: 2px; min-width: 3px; }
.pc-bar-labels {
display: flex;
gap: 10px;
}
.pc-bar-label {
font-size: 9px;
font-weight: 300;
letter-spacing: 0.04em;
}
.pc-cta {
position: relative;
z-index: 1;
font-size: 11px;
font-weight: 400;
color: #a5b4fc;
letter-spacing: 0.04em;
padding-top: 4px;
border-top: 1px solid rgba(255,255,255,0.08);
}
</style>

View File

@@ -42,7 +42,7 @@
<!-- Trip badge — top-right of card, outside photo --> <!-- Trip badge — top-right of card, outside photo -->
<span class="trip-badge trip-badge--{entry.tripType}"> <span class="trip-badge trip-badge--{entry.tripType}">
{entry.tripType === 'solo' ? 'Solo' : 'Friends'} {entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
</span> </span>
<!-- Photos --> <!-- Photos -->
@@ -99,7 +99,7 @@
<!-- Info bar --> <!-- Info bar -->
<div class="card-info"> <div class="card-info">
<span class="city">{entry.location.city}</span> <span class="city">{entry.location.cities.join(', ')}</span>
<div class="meta"> <div class="meta">
{#if entry.transport} {#if entry.transport}
<span class="transport-chip transport-chip--{entry.transport}"> <span class="transport-chip transport-chip--{entry.transport}">
@@ -206,6 +206,7 @@
} }
.trip-badge--solo { background: rgba(245,158,11,0.85); color: #fff; } .trip-badge--solo { background: rgba(245,158,11,0.85); color: #fff; }
.trip-badge--friends { background: rgba(124,58,237,0.85); color: #fff; } .trip-badge--friends { background: rgba(124,58,237,0.85); color: #fff; }
.trip-badge--family { background: rgba(16,185,129,0.85); color: #fff; }
/* ── Photo grid — fixed height, always consistent ── */ /* ── Photo grid — fixed height, always consistent ── */
.photo-grid { .photo-grid {

View File

@@ -5,7 +5,9 @@
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';
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props(); let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null)); let selectedId = $state(/** @type {string|null} */(null));
@@ -53,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">
@@ -69,34 +71,25 @@
</div> </div>
{:else} {:else}
<div class="right-panel"> <div class="right-panel">
<div class="right-inner"> <div class="center-col">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">My Journey</h1> <h1 class="page-title">My Journey</h1>
<button class="new-btn" onclick={() => { view = 'new'; }}> <button class="new-btn" onclick={() => { view = 'new'; }}>
<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>
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length > 0} {#if sortedEntries.length > 0}
<button class="share-nudge" onclick={() => (showShare = true)}> <div class="share-row">
<span class="nudge-left"> <SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> </div>
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
</svg>
Share your journey
</span>
<span class="nudge-right">
{sortedEntries.length} {sortedEntries.length === 1 ? 'trip' : 'trips'} · save as PNG →
</span>
</button>
{/if} {/if}
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
{#if sortedEntries.length === 0} {#if sortedEntries.length === 0}
<p class="empty">No journal entries yet.</p> <p class="empty">No journal entries yet.</p>
{:else} {:else}
@@ -122,7 +115,7 @@
{/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>
</div> </div>
@@ -144,16 +137,6 @@
overflow: hidden; overflow: hidden;
} }
/* ── Left panel ── */
.left-panel {
width: 260px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--bg-raised);
padding: 40px 28px;
}
/* ── Right panel ── */ /* ── Right panel ── */
.right-panel { .right-panel {
flex: 1; flex: 1;
@@ -162,24 +145,23 @@
background: var(--bg); background: var(--bg);
} }
/* Inner container with max-width + generous side padding */ /* ── Centered single column ── */
.right-inner { .center-col {
max-width: 640px; max-width: 680px;
width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 40px 48px 80px; padding: 48px 48px 80px;
box-sizing: border-box;
} }
/* ── Responsive: narrow viewport ── */ .share-row {
@media (max-width: 700px) { margin-bottom: 24px;
.journal-page { flex-direction: column; overflow-y: auto; overflow-x: hidden; } }
.left-panel {
width: 100%; @media (max-width: 760px) {
border-right: none; .center-col {
border-bottom: 1px solid var(--border); padding: 32px 24px 60px;
padding: 24px 20px;
} }
.right-panel { overflow-y: unset; }
.right-inner { padding: 24px 20px 60px; }
} }
/* ── 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>

View File

@@ -1,7 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
}) })