changed to centered layout
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Map-Jurnal",
|
||||
"cwd": "/Users/haerikim/Desktop/Map-Jurnal",
|
||||
"cwd": "/Users/haerikim/Desktop/SP Map Journal/Map-Jurnal",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
|
||||
1347
package-lock.json
generated
1347
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"svelte": "^5.55.5",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Searchable combobox input.
|
||||
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
|
||||
*/
|
||||
let { id, value = $bindable(), options, placeholder = '', required = false } = $props();
|
||||
let { id, value = $bindable(), options, placeholder = '', required = false, onselect } = $props();
|
||||
|
||||
let query = $state(value);
|
||||
let open = $state(false);
|
||||
@@ -20,6 +20,7 @@
|
||||
value = opt;
|
||||
open = false;
|
||||
focused = -1;
|
||||
onselect?.(opt);
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
@@ -33,7 +34,7 @@
|
||||
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
|
||||
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 === '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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import { writable } from 'svelte/store';
|
||||
* id: string,
|
||||
* title: string,
|
||||
* date: string,
|
||||
* location: { country: string, city: string },
|
||||
* location: { country: string, cities: string[] },
|
||||
* photos: string[],
|
||||
* transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk',
|
||||
* tripType: 'solo' | 'friends',
|
||||
* tripType: 'solo' | 'friends' | 'family',
|
||||
* days: number,
|
||||
* memo: string
|
||||
* }} JournalEntry
|
||||
@@ -20,7 +20,7 @@ const mockEntries = [
|
||||
id: '1',
|
||||
title: 'First Day in Tokyo',
|
||||
date: '2024-03-15',
|
||||
location: { country: 'Japan', city: 'Tokyo' },
|
||||
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',
|
||||
@@ -35,7 +35,7 @@ const mockEntries = [
|
||||
id: '2',
|
||||
title: 'Arashiyama Bamboo Grove',
|
||||
date: '2024-03-18',
|
||||
location: { country: 'Japan', city: 'Kyoto' },
|
||||
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',
|
||||
@@ -49,7 +49,7 @@ const mockEntries = [
|
||||
id: '3',
|
||||
title: 'Sunset on Montmartre',
|
||||
date: '2024-06-02',
|
||||
location: { country: 'France', city: 'Paris' },
|
||||
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',
|
||||
@@ -64,7 +64,7 @@ const mockEntries = [
|
||||
id: '4',
|
||||
title: 'Inside La Sagrada Família',
|
||||
date: '2024-06-10',
|
||||
location: { country: 'Spain', city: 'Barcelona' },
|
||||
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',
|
||||
@@ -78,7 +78,7 @@ const mockEntries = [
|
||||
id: '5',
|
||||
title: 'Central Park in Fall',
|
||||
date: '2023-10-20',
|
||||
location: { country: 'USA', city: 'New York' },
|
||||
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',
|
||||
@@ -93,7 +93,7 @@ const mockEntries = [
|
||||
id: '6',
|
||||
title: 'Wat Pho Reclining Buddha',
|
||||
date: '2024-01-08',
|
||||
location: { country: 'Thailand', city: 'Bangkok' },
|
||||
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',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="dialog">
|
||||
<h2 class="title">Delete entry?</h2>
|
||||
<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>
|
||||
<div class="actions">
|
||||
<button class="btn btn-cancel" onclick={onCancel}>Cancel</button>
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
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 date = $state(entry?.date ?? new Date().toISOString().slice(0, 10));
|
||||
let days = $state(String(entry?.days ?? 1));
|
||||
@@ -49,20 +50,32 @@
|
||||
}
|
||||
|
||||
let cityOptions = $derived(
|
||||
[...new Set(get(journals).map(e => e.location.city))].sort()
|
||||
[...new Set(get(journals).flatMap(e => e.location.cities))].sort()
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (isNew) {
|
||||
addJournal({
|
||||
title: `${city}, ${country}`,
|
||||
title: `${cities.join(', ')}, ${country}`,
|
||||
date,
|
||||
days: Number(days),
|
||||
tripType,
|
||||
memo,
|
||||
photos,
|
||||
transport,
|
||||
location: { city, country },
|
||||
location: { cities, country },
|
||||
});
|
||||
} else {
|
||||
updateJournal({
|
||||
@@ -73,7 +86,7 @@
|
||||
transport,
|
||||
memo,
|
||||
photos,
|
||||
location: { city, country },
|
||||
location: { cities, country },
|
||||
});
|
||||
}
|
||||
onBack();
|
||||
@@ -106,8 +119,20 @@
|
||||
<SearchInput id="edit-country" bind:value={country} options={countryNames} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="edit-city">City <span class="req">*</span></label>
|
||||
<SearchInput id="edit-city" bind:value={city} options={cityOptions} required />
|
||||
<label class="label" for="edit-city">Cities <span class="req">*</span></label>
|
||||
<div class="city-input-row">
|
||||
<SearchInput id="edit-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||
</div>
|
||||
{#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>
|
||||
|
||||
@@ -131,6 +156,9 @@
|
||||
<label class="toggle-opt" class:active={tripType === 'friends'}>
|
||||
<input type="radio" name="tripType" value="friends" bind:group={tripType} /> With friends
|
||||
</label>
|
||||
<label class="toggle-opt" class:active={tripType === 'family'}>
|
||||
<input type="radio" name="tripType" value="family" bind:group={tripType} /> With family
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -355,4 +383,39 @@
|
||||
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>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<span class="topbar-flag">{flagEmoji(entry.location.country)}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,6 +114,8 @@
|
||||
<p class="answer">
|
||||
{#if entry.tripType === 'solo'}
|
||||
Just me — solo trip
|
||||
{:else if entry.tripType === 'family'}
|
||||
With family
|
||||
{:else}
|
||||
With friends
|
||||
{/if}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
||||
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 minYear = Math.min(...years);
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
<div class="photo-editor">
|
||||
<div class="label-row">
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
const totalDays = entries.reduce((s, e) => s + e.days, 0);
|
||||
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
|
||||
const contDays = {};
|
||||
@@ -68,6 +68,7 @@
|
||||
// Solo vs friends
|
||||
const soloCount = entries.filter(e => e.tripType === 'solo').length;
|
||||
const friendCount = entries.filter(e => e.tripType === 'friends').length;
|
||||
const familyCount = entries.filter(e => e.tripType === 'family').length;
|
||||
|
||||
// Most visited country
|
||||
const countryCounts = {};
|
||||
@@ -79,7 +80,7 @@
|
||||
|
||||
return {
|
||||
totalDays, countries, cities, contDays, topContinent,
|
||||
longest, flightHrs, soloCount, friendCount,
|
||||
longest, flightHrs, soloCount, friendCount, familyCount,
|
||||
favCountry, spanMonths, yearStart, yearEnd,
|
||||
};
|
||||
});
|
||||
@@ -175,7 +176,7 @@
|
||||
{#if stats.longest}
|
||||
<div class="fact">
|
||||
<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>
|
||||
{/if}
|
||||
{#if stats.flightHrs > 0}
|
||||
@@ -192,7 +193,7 @@
|
||||
{/if}
|
||||
<div class="fact">
|
||||
<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>
|
||||
|
||||
|
||||
235
src/lib/timeline/SharePreview.svelte
Normal file
235
src/lib/timeline/SharePreview.svelte
Normal 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>
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<!-- Trip badge — top-right of card, outside photo -->
|
||||
<span class="trip-badge trip-badge--{entry.tripType}">
|
||||
{entry.tripType === 'solo' ? 'Solo' : 'Friends'}
|
||||
{entry.tripType === 'solo' ? 'Solo' : entry.tripType === 'family' ? 'Family' : 'Friends'}
|
||||
</span>
|
||||
|
||||
<!-- Photos -->
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<!-- Info bar -->
|
||||
<div class="card-info">
|
||||
<span class="city">{entry.location.city}</span>
|
||||
<span class="city">{entry.location.cities.join(', ')}</span>
|
||||
<div class="meta">
|
||||
{#if 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--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 {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import JournalDetail from './JournalDetail.svelte';
|
||||
import EditForm from './EditForm.svelte';
|
||||
import ShareCard from './ShareCard.svelte';
|
||||
import SharePreview from './SharePreview.svelte';
|
||||
|
||||
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props();
|
||||
let selectedId = $state(/** @type {string|null} */(null));
|
||||
@@ -69,61 +70,56 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="right-panel">
|
||||
<div class="right-inner">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">My Journey</h1>
|
||||
<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">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
New entry
|
||||
</button>
|
||||
<div class="two-col">
|
||||
<!-- Timeline column -->
|
||||
<div class="timeline-col">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">My Journey</h1>
|
||||
<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">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
New entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
|
||||
|
||||
{#if sortedEntries.length === 0}
|
||||
<p class="empty">No journal entries yet.</p>
|
||||
{:else}
|
||||
<div class="sort-row">
|
||||
<span class="sort-label">Sort by</span>
|
||||
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
|
||||
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
|
||||
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
|
||||
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A → Z</option>
|
||||
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z → A</option>
|
||||
</select>
|
||||
</div>
|
||||
<ol class="v-list">
|
||||
{#each sortedEntries as entry, i (entry.id)}
|
||||
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
|
||||
<li class="year-marker" aria-hidden="true">
|
||||
<span class="year-label">{getYear(entry.date)}</span>
|
||||
</li>
|
||||
{/if}
|
||||
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<footer class="page-footer">
|
||||
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<TimelineToolbar {sortKey} onSort={(k) => (sortKey = k)} />
|
||||
|
||||
<!-- Share preview column -->
|
||||
{#if sortedEntries.length > 0}
|
||||
<button class="share-nudge" onclick={() => (showShare = true)}>
|
||||
<span class="nudge-left">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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 sortedEntries.length === 0}
|
||||
<p class="empty">No journal entries yet.</p>
|
||||
{:else}
|
||||
<div class="sort-row">
|
||||
<span class="sort-label">Sort by</span>
|
||||
<select class="sort-select" onchange={(e) => (sortKey = e.currentTarget.value)}>
|
||||
<option value="date-desc" selected={sortKey === 'date-desc'}>Newest first</option>
|
||||
<option value="date-asc" selected={sortKey === 'date-asc'}>Oldest first</option>
|
||||
<option value="country-asc" selected={sortKey === 'country-asc'}>Country A → Z</option>
|
||||
<option value="country-desc" selected={sortKey === 'country-desc'}>Country Z → A</option>
|
||||
</select>
|
||||
<div class="share-col">
|
||||
<SharePreview entries={sortedEntries} onClick={() => (showShare = true)} />
|
||||
</div>
|
||||
<ol class="v-list">
|
||||
{#each sortedEntries as entry, i (entry.id)}
|
||||
{#if i === 0 || getYear(entry.date) !== getYear(sortedEntries[i - 1].date)}
|
||||
<li class="year-marker" aria-hidden="true">
|
||||
<span class="year-label">{getYear(entry.date)}</span>
|
||||
</li>
|
||||
{/if}
|
||||
<TimelineCard {entry} onClick={() => { selectedId = entry.id; view = 'detail'; onDetailChange(true); }} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<footer class="page-footer">
|
||||
{sortedEntries.length} {sortedEntries.length === 1 ? 'entry' : 'entries'}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -144,16 +140,6 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
@@ -162,24 +148,30 @@
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Inner container with max-width + generous side padding */
|
||||
.right-inner {
|
||||
max-width: 640px;
|
||||
/* ── Two-column layout ── */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 48px;
|
||||
max-width: 1020px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 40px 48px 80px;
|
||||
padding: 48px 48px 80px;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.timeline-col { min-width: 0; }
|
||||
|
||||
.share-col { padding-top: 60px; }
|
||||
|
||||
/* ── Responsive: narrow viewport ── */
|
||||
@media (max-width: 700px) {
|
||||
.journal-page { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
.left-panel {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 24px 20px;
|
||||
@media (max-width: 860px) {
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 32px 24px 60px;
|
||||
}
|
||||
.right-panel { overflow-y: unset; }
|
||||
.right-inner { padding: 24px 20px 60px; }
|
||||
.share-col { padding-top: 0; }
|
||||
}
|
||||
|
||||
/* ── Detail view ── */
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user