- EditForm: refactored to 3-step flow matching NewEntryForm (details → photos → memo), transport icons with bigger images - ShareCard: profile image, "You've colored X% of the world map" hero stat, removed stat boxes and continent bar, font embedding fix for PNG export, renamed brand to Journi - JourneyView: auto-close after journey complete - WorldMap: removed home marker icon, fix home marker reposition on resize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
523 lines
15 KiB
Svelte
523 lines
15 KiB
Svelte
<script>
|
||
import { toPng } from 'html-to-image';
|
||
import { getTotalCount } from '../../layout/selection.svelte.js';
|
||
import profileImg from '../../../assets/profile.png';
|
||
|
||
/** @type {{ entries: import('../shared/types.js').JournalEntry[], onClose: () => void }} */
|
||
let { entries, onClose } = $props();
|
||
|
||
let cardEl = $state(null);
|
||
let downloading = $state(false);
|
||
|
||
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', 'Bangladesh': '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', 'Colombia': 'S. America',
|
||
'Australia': 'Oceania', 'New Zealand': 'Oceania',
|
||
'Morocco': 'Africa', 'Egypt': 'Africa', 'Kenya': 'Africa', 'South Africa': 'Africa',
|
||
};
|
||
|
||
const flightHoursMap = {
|
||
'Japan': 13, 'South Korea': 13, 'China': 12, 'Thailand': 11, 'Vietnam': 12,
|
||
'Indonesia': 14, 'Malaysia': 11, 'Singapore': 11, 'India': 9, 'Taiwan': 13,
|
||
'France': 9, 'Spain': 10, 'Italy': 10, 'Germany': 9, 'UK': 8, 'Netherlands': 9,
|
||
'Portugal': 9, 'Greece': 11, 'Sweden': 10, 'Norway': 9, 'Switzerland': 9,
|
||
'Turkey': 11, 'Austria': 10, 'Belgium': 9, 'Poland': 10,
|
||
'USA': 14, 'Canada': 13, 'Mexico': 13,
|
||
'Brazil': 10, 'Argentina': 12, 'Chile': 13,
|
||
'Australia': 20, 'New Zealand': 23,
|
||
'Morocco': 10, 'Egypt': 12, 'Kenya': 14, 'South Africa': 16,
|
||
};
|
||
|
||
let stats = $derived.by(() => {
|
||
if (entries.length === 0) return null;
|
||
|
||
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.flatMap(e => e.location.cities))];
|
||
|
||
// Continent days
|
||
const contDays = {};
|
||
for (const e of entries) {
|
||
const cont = continentMap[e.location.country] ?? 'Other';
|
||
contDays[cont] = (contDays[cont] ?? 0) + e.days;
|
||
}
|
||
const topContinent = Object.entries(contDays).sort((a, b) => b[1] - a[1])[0];
|
||
|
||
// Longest trip
|
||
const longest = [...entries].sort((a, b) => b.days - a.days)[0];
|
||
|
||
// Date range
|
||
const dates = entries.map(e => new Date(e.date));
|
||
const firstDate = new Date(Math.min(...dates));
|
||
const lastDate = new Date(Math.max(...dates));
|
||
const spanDays = Math.round((lastDate - firstDate) / 86400000);
|
||
const spanMonths = Math.round(spanDays / 30);
|
||
|
||
// Flight hours estimate
|
||
const flightTrips = entries.filter(e => e.transport === 'flight');
|
||
const flightHrs = flightTrips.reduce((s, e) => s + (flightHoursMap[e.location.country] ?? 10), 0);
|
||
|
||
// 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 = {};
|
||
for (const e of entries) countryCounts[e.location.country] = (countryCounts[e.location.country] ?? 0) + 1;
|
||
const favCountry = Object.entries(countryCounts).sort((a, b) => b[1] - a[1])[0];
|
||
|
||
const yearStart = firstDate.getFullYear();
|
||
const yearEnd = lastDate.getFullYear();
|
||
|
||
return {
|
||
totalDays, countries, cities, contDays, topContinent,
|
||
longest, flightHrs, soloCount, friendCount, familyCount,
|
||
favCountry, spanMonths, yearStart, yearEnd,
|
||
};
|
||
});
|
||
|
||
async function getFontDataUrl() {
|
||
// Get the actual woff2 URL the browser resolved for Bricolage Grotesque
|
||
for (const font of document.fonts) {
|
||
if (font.family.includes('Bricolage')) {
|
||
// font.status === 'loaded' means the browser has it
|
||
await font.load();
|
||
}
|
||
}
|
||
// Fetch the Google Fonts CSS to extract the real woff2 URL
|
||
const cssRes = await fetch(
|
||
'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500&display=swap',
|
||
{ headers: { 'User-Agent': 'Mozilla/5.0' } }
|
||
);
|
||
const css = await cssRes.text();
|
||
const match = css.match(/url\((https:\/\/fonts\.gstatic\.com[^)]+\.woff2)\)/);
|
||
if (!match) return null;
|
||
const fontRes = await fetch(match[1]);
|
||
const blob = await fontRes.blob();
|
||
return new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => resolve(reader.result);
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
async function download() {
|
||
if (!cardEl) return;
|
||
downloading = true;
|
||
try {
|
||
await document.fonts.ready;
|
||
|
||
let fontFaceRule = '';
|
||
try {
|
||
const fontData = await getFontDataUrl();
|
||
if (fontData) {
|
||
fontFaceRule = `@font-face { font-family: 'Bricolage Grotesque'; src: url('${fontData}') format('woff2'); font-weight: 100 900; font-style: normal; }`;
|
||
}
|
||
} catch {}
|
||
|
||
// Inject font as a real <style> inside the card so html-to-image clones it
|
||
let injected = null;
|
||
if (fontFaceRule) {
|
||
injected = document.createElement('style');
|
||
injected.textContent = fontFaceRule;
|
||
cardEl.prepend(injected);
|
||
}
|
||
|
||
const opts = { pixelRatio: 3 };
|
||
await toPng(cardEl, opts); // first pass loads resources
|
||
const dataUrl = await toPng(cardEl, opts);
|
||
|
||
if (injected) injected.remove();
|
||
|
||
const a = document.createElement('a');
|
||
a.download = 'my-journey.png';
|
||
a.href = dataUrl;
|
||
a.click();
|
||
} finally {
|
||
downloading = false;
|
||
}
|
||
}
|
||
|
||
function fmtYear(y1, y2) {
|
||
return y1 === y2 ? `${y1}` : `${y1} – ${y2}`;
|
||
}
|
||
</script>
|
||
|
||
<!-- Overlay -->
|
||
<div class="overlay" role="dialog" aria-modal="true">
|
||
<div class="overlay-inner">
|
||
|
||
<!-- Controls -->
|
||
<div class="controls">
|
||
<button class="ctrl-btn" onclick={onClose}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||
<path d="M18 6 6 18M6 6l12 12"/>
|
||
</svg>
|
||
Close
|
||
</button>
|
||
<button class="ctrl-btn ctrl-btn--primary" onclick={download} disabled={downloading}>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||
</svg>
|
||
{downloading ? 'Saving…' : 'Save as PNG'}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- The card (Instagram story 9:16) -->
|
||
{#if stats}
|
||
<div class="card-wrap">
|
||
<div class="card" bind:this={cardEl}>
|
||
|
||
<!-- Background layers -->
|
||
<div class="bg-gradient"></div>
|
||
<div class="bg-grid"></div>
|
||
|
||
<!-- Header -->
|
||
<div class="card-header">
|
||
<span class="card-brand">JOURNI</span>
|
||
<span class="card-year">{fmtYear(stats.yearStart, stats.yearEnd)}</span>
|
||
</div>
|
||
|
||
<!-- Profile -->
|
||
<div class="profile-wrap">
|
||
<img class="profile-img" src={profileImg} alt="profile" />
|
||
</div>
|
||
|
||
<!-- Hero stat -->
|
||
<div class="hero">
|
||
<p class="hero-pre">You've colored</p>
|
||
<p class="big-num">{getTotalCount() > 0 ? Math.round((stats.countries.length / getTotalCount()) * 100) : 0}%</p>
|
||
<p class="hero-post">of the world map.</p>
|
||
</div>
|
||
|
||
|
||
<!-- Fun facts -->
|
||
<div class="facts">
|
||
{#if stats.topContinent}
|
||
<div class="fact">
|
||
<span class="fact-icon">🌏</span>
|
||
<span class="fact-text">Spent <strong>{stats.topContinent[1]} days</strong> in {stats.topContinent[0]}</span>
|
||
</div>
|
||
{/if}
|
||
{#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.cities.join(', ')}</span>
|
||
</div>
|
||
{/if}
|
||
{#if stats.flightHrs > 0}
|
||
<div class="fact">
|
||
<span class="fact-icon">✈️</span>
|
||
<span class="fact-text">~<strong>{stats.flightHrs} hrs</strong> crossing skies</span>
|
||
</div>
|
||
{/if}
|
||
{#if stats.favCountry && stats.favCountry[1] > 1}
|
||
<div class="fact">
|
||
<span class="fact-icon">❤️</span>
|
||
<span class="fact-text">Kept coming back to <strong>{stats.favCountry[0]}</strong></span>
|
||
</div>
|
||
{/if}
|
||
<div class="fact">
|
||
<span class="fact-icon">{stats.soloCount >= stats.friendCount ? '🧳' : '👥'}</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>
|
||
|
||
|
||
<!-- Footer -->
|
||
<div class="card-footer">
|
||
<span>journi</span>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
/* ── Overlay ── */
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.7);
|
||
z-index: 200;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.overlay-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* ── Controls ── */
|
||
.controls {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
.ctrl-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
font-family: var(--sans);
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
padding: 8px 18px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
background: rgba(255,255,255,0.1);
|
||
color: #fff;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
.ctrl-btn:hover { background: rgba(255,255,255,0.18); }
|
||
.ctrl-btn--primary {
|
||
background: #7c3aed;
|
||
border-color: #7c3aed;
|
||
}
|
||
.ctrl-btn--primary:hover { background: #6d28d9; }
|
||
.ctrl-btn:disabled { opacity: 0.6; cursor: default; }
|
||
|
||
/* ── Card wrap (scrollable preview) ── */
|
||
.card-wrap {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Card itself (Instagram story 9:16 → 360×640 preview) ── */
|
||
.card {
|
||
width: 360px;
|
||
min-height: 640px;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 32px 28px 24px;
|
||
gap: 0;
|
||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||
background: #0f0a1e;
|
||
color: #fff;
|
||
|
||
/* continent color vars */
|
||
--cont-asia: #f87171;
|
||
--cont-europe: #818cf8;
|
||
--cont-africa: #fb923c;
|
||
--cont-namerica: #4ade80;
|
||
--cont-n-america: #4ade80;
|
||
--cont-samerica: #fbbf24;
|
||
--cont-s-america: #fbbf24;
|
||
--cont-oceania: #c084fc;
|
||
}
|
||
|
||
/* Backgrounds */
|
||
.bg-gradient {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(ellipse 60% 40% at 80% 10%, rgba(124,58,237,0.35) 0%, transparent 70%),
|
||
radial-gradient(ellipse 50% 50% at 10% 80%, rgba(99,102,241,0.25) 0%, transparent 60%);
|
||
pointer-events: none;
|
||
}
|
||
.bg-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image:
|
||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||
background-size: 32px 32px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Header */
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 36px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.card-brand {
|
||
font-size: 9px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.22em;
|
||
color: #a5b4fc;
|
||
}
|
||
.card-year {
|
||
font-size: 11px;
|
||
font-weight: 300;
|
||
color: rgba(255,255,255,0.4);
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
/* Profile */
|
||
.profile-wrap {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.profile-img {
|
||
width: 192px;
|
||
height: 192px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
/* Hero */
|
||
.hero {
|
||
position: relative;
|
||
z-index: 1;
|
||
margin-bottom: 28px;
|
||
}
|
||
.hero-pre, .hero-post {
|
||
font-size: 16px;
|
||
font-weight: 300;
|
||
color: rgba(255,255,255,0.6);
|
||
letter-spacing: 0.04em;
|
||
margin: 0;
|
||
}
|
||
.big-num {
|
||
font-size: 88px;
|
||
font-weight: 500;
|
||
color: #fff;
|
||
letter-spacing: -4px;
|
||
line-height: 1;
|
||
margin: 8px 0;
|
||
}
|
||
|
||
/* Stat grid */
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 8px;
|
||
margin-bottom: 24px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.stat-box {
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 10px;
|
||
padding: 10px 8px;
|
||
text-align: center;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.stat-num {
|
||
font-size: 22px;
|
||
font-weight: 400;
|
||
color: #fff;
|
||
letter-spacing: -0.5px;
|
||
line-height: 1;
|
||
margin-bottom: 4px;
|
||
}
|
||
.stat-desc {
|
||
font-size: 9px;
|
||
font-weight: 400;
|
||
color: rgba(255,255,255,0.4);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
}
|
||
|
||
/* Facts */
|
||
.facts {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-bottom: 24px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.fact {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: rgba(255,255,255,0.04);
|
||
border: 1px solid rgba(255,255,255,0.07);
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
}
|
||
.fact-icon { font-size: 16px; flex-shrink: 0; }
|
||
.fact-text {
|
||
font-size: 12px;
|
||
font-weight: 300;
|
||
color: rgba(255,255,255,0.7);
|
||
line-height: 1.4;
|
||
}
|
||
.fact-text strong { color: #fff; font-weight: 400; }
|
||
|
||
/* Continent bar */
|
||
.cont-section {
|
||
position: relative;
|
||
z-index: 1;
|
||
margin-bottom: 20px;
|
||
}
|
||
.cont-bar {
|
||
display: flex;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
gap: 2px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.cont-seg { border-radius: 3px; min-width: 4px; }
|
||
.cont-legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px 12px;
|
||
}
|
||
.cont-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 9px;
|
||
font-weight: 300;
|
||
color: rgba(255,255,255,0.5);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.cont-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Footer */
|
||
.card-footer {
|
||
margin-top: auto;
|
||
padding-top: 16px;
|
||
border-top: 1px solid rgba(255,255,255,0.08);
|
||
font-size: 9px;
|
||
font-weight: 300;
|
||
color: rgba(255,255,255,0.25);
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
</style>
|