Compare commits
3 Commits
cd682f738a
...
f198c05063
| Author | SHA1 | Date | |
|---|---|---|---|
| f198c05063 | |||
| 6701398da7 | |||
| 08d3e3ae56 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
.env
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
1047
package-lock.json
generated
1047
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"firebase": "^12.14.0",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"world-atlas": "^2.0.2"
|
"world-atlas": "^2.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { initAuth, getLoading, getUser, getNeedsCountry } from './lib/auth/userStore.svelte.js';
|
||||||
|
import LoginOverlay from './lib/auth/LoginOverlay.svelte';
|
||||||
|
import CountryPicker from './lib/auth/CountryPicker.svelte';
|
||||||
import Layout from './lib/layout/Layout.svelte';
|
import Layout from './lib/layout/Layout.svelte';
|
||||||
import WorldMap from './lib/world-map/WorldMap.svelte';
|
import WorldMap from './lib/world-map/WorldMap.svelte';
|
||||||
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
||||||
@@ -9,22 +12,54 @@
|
|||||||
function onNavigate(s) {
|
function onNavigate(s) {
|
||||||
screen = s;
|
screen = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
initAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
let loading = $derived(getLoading());
|
||||||
|
let user = $derived(getUser());
|
||||||
|
let needsCountry = $derived(getNeedsCountry());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout {screen} {onNavigate}>
|
{#if loading}
|
||||||
{#if screen === 'worldmap'}
|
<div class="loading-screen">
|
||||||
<div class="worldmap-page">
|
<span class="loading-text">Loading...</span>
|
||||||
<div class="map-area">
|
</div>
|
||||||
<WorldMap />
|
{:else}
|
||||||
|
<Layout {screen} {onNavigate}>
|
||||||
|
{#if screen === 'worldmap'}
|
||||||
|
<div class="worldmap-page">
|
||||||
|
<div class="map-area"><WorldMap /></div>
|
||||||
|
<StatsPanel />
|
||||||
</div>
|
</div>
|
||||||
<StatsPanel />
|
{:else}
|
||||||
</div>
|
<TimelineView />
|
||||||
{:else}
|
{/if}
|
||||||
<TimelineView />
|
</Layout>
|
||||||
|
|
||||||
|
{#if !user}
|
||||||
|
<LoginOverlay />
|
||||||
|
{:else if needsCountry}
|
||||||
|
<CountryPicker />
|
||||||
{/if}
|
{/if}
|
||||||
</Layout>
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.loading-screen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font: 400 18px/1.4 sans-serif;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.worldmap-page {
|
.worldmap-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
/** @type {{ entry: import('./stores/journalStore.js').JournalEntry, onBack: () => void }} */
|
|
||||||
let { entry, onBack } = $props();
|
let { entry, onBack } = $props();
|
||||||
|
|
||||||
let photoIdx = $state(0);
|
let photoIdx = $state(0);
|
||||||
@@ -16,6 +15,25 @@
|
|||||||
function next() {
|
function next() {
|
||||||
photoIdx = (photoIdx + 1) % entry.photos.length;
|
photoIdx = (photoIdx + 1) % entry.photos.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tripType(/** @type {string[] | undefined} */ companions) {
|
||||||
|
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'solo';
|
||||||
|
return 'friends';
|
||||||
|
}
|
||||||
|
|
||||||
|
function companionText(/** @type {string[] | undefined} */ companions) {
|
||||||
|
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'Solo';
|
||||||
|
return companions.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_LABELS = {
|
||||||
|
plane: '✈️ Plane',
|
||||||
|
car: '🚗 Car',
|
||||||
|
train: '🚆 Train',
|
||||||
|
boat: '⛵ Boat',
|
||||||
|
bus: '🚌 Bus',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="detail-page">
|
<article class="detail-page">
|
||||||
@@ -28,7 +46,7 @@
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if entry.photos.length > 0}
|
{#if entry.photos?.length > 0}
|
||||||
<div class="hero-gallery">
|
<div class="hero-gallery">
|
||||||
<img
|
<img
|
||||||
class="hero-img"
|
class="hero-img"
|
||||||
@@ -58,10 +76,13 @@
|
|||||||
|
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="badge loc-badge">📍 {entry.location.city}, {entry.location.country}</span>
|
<span class="badge loc-badge">📍 {entry.city}, {entry.countryName}</span>
|
||||||
<span class="badge trip-badge trip-badge--{entry.tripType}">
|
<span class="badge" class:trip-badge--solo={tripType(entry.companions) === 'solo'} class:trip-badge--friends={tripType(entry.companions) === 'friends'}>
|
||||||
{entry.tripType === 'solo' ? '🧍 Solo' : '👥 With Friends'}
|
{tripType(entry.companions) === 'solo' ? '🧍 Solo' : '👥 ' + companionText(entry.companions)}
|
||||||
</span>
|
</span>
|
||||||
|
{#if entry.transportation}
|
||||||
|
<span class="badge transport-badge">{TRANSPORT_LABELS[entry.transportation] || entry.transportation}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="detail-title">{entry.title}</h1>
|
<h1 class="detail-title">{entry.title}</h1>
|
||||||
@@ -93,8 +114,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="song-text">
|
<div class="song-text">
|
||||||
<span class="song-label">Soundtrack</span>
|
<span class="song-label">Soundtrack</span>
|
||||||
<span class="song-name">{entry.song.title}</span>
|
<span class="song-name">{entry.song?.title || ''}</span>
|
||||||
<span class="song-artist">{entry.song.artist}</span>
|
<span class="song-artist">{entry.song?.artist || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,6 +244,11 @@
|
|||||||
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
|
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
|
||||||
.trip-badge--friends { background: rgba(59,130,246,0.12); color: #1d4ed8; }
|
.trip-badge--friends { background: rgba(59,130,246,0.12); color: #1d4ed8; }
|
||||||
|
|
||||||
|
.transport-badge {
|
||||||
|
background: rgba(16,185,129,0.12);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
import { get } from 'svelte/store';
|
import { getEntries } from './stores/entriesStore.svelte.js';
|
||||||
import { journals } from './stores/journalStore.js';
|
|
||||||
import JournalDetail from './JournalDetail.svelte';
|
import JournalDetail from './JournalDetail.svelte';
|
||||||
|
|
||||||
/** @type {import('./stores/journalStore.js').JournalEntry|null} */
|
/** @type {Record<string, number>} */
|
||||||
let selected = $state(null);
|
let selected = $state(null);
|
||||||
|
|
||||||
/** @type {Record<string, number>} */
|
/** @type {Record<string, number>} */
|
||||||
let photoIdx = $state({});
|
let photoIdx = $state({});
|
||||||
|
|
||||||
// Store subscription
|
let entries = $derived(getEntries());
|
||||||
let entries = $state(get(journals));
|
|
||||||
$effect(() => {
|
|
||||||
const unsub = journals.subscribe((v) => { entries = v; });
|
|
||||||
return unsub;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: 'date-desc', label: 'Newest First' },
|
{ value: 'date-desc', label: 'Newest First' },
|
||||||
@@ -25,15 +19,13 @@
|
|||||||
|
|
||||||
let sortKey = $state('date-desc');
|
let sortKey = $state('date-desc');
|
||||||
|
|
||||||
// Explicit $effect so sortKey changes always trigger a re-sort
|
let sortedEntries = $derived.by(() => {
|
||||||
let sortedEntries = $state(/** @type {typeof entries} */([]));
|
|
||||||
$effect(() => {
|
|
||||||
const key = sortKey;
|
const key = sortKey;
|
||||||
sortedEntries = [...entries].sort((a, b) => {
|
return [...entries].sort((a, b) => {
|
||||||
if (key === 'date-asc') return a.date.localeCompare(b.date);
|
if (key === 'date-asc') return a.date.localeCompare(b.date);
|
||||||
if (key === 'date-desc') return b.date.localeCompare(a.date);
|
if (key === 'date-desc') return b.date.localeCompare(a.date);
|
||||||
if (key === 'country-asc') return a.location.country.localeCompare(b.location.country) || b.date.localeCompare(a.date);
|
if (key === 'country-asc') return (a.countryName || '').localeCompare(b.countryName || '') || b.date.localeCompare(a.date);
|
||||||
if (key === 'country-desc') return b.location.country.localeCompare(a.location.country) || b.date.localeCompare(a.date);
|
if (key === 'country-desc') return (b.countryName || '').localeCompare(a.countryName || '') || b.date.localeCompare(a.date);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -54,6 +46,25 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
photoIdx = { ...photoIdx, [id]: i };
|
photoIdx = { ...photoIdx, [id]: i };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tripType(/** @type {string[] | undefined} */ companions) {
|
||||||
|
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'solo';
|
||||||
|
return 'friends';
|
||||||
|
}
|
||||||
|
|
||||||
|
function companionText(/** @type {string[] | undefined} */ companions) {
|
||||||
|
if (!companions || companions.length === 0 || (companions.length === 1 && companions[0] === 'solo')) return 'Solo';
|
||||||
|
return companions.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_LABELS = {
|
||||||
|
plane: '✈️ Plane',
|
||||||
|
car: '🚗 Car',
|
||||||
|
train: '🚆 Train',
|
||||||
|
boat: '⛵ Boat',
|
||||||
|
bus: '🚌 Bus',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if selected}
|
{#if selected}
|
||||||
@@ -88,7 +99,7 @@
|
|||||||
<div class="above-card">
|
<div class="above-card">
|
||||||
<time class="above-date" datetime={entry.date}>{formatDate(entry.date)}</time>
|
<time class="above-date" datetime={entry.date}>{formatDate(entry.date)}</time>
|
||||||
<span class="above-sep">·</span>
|
<span class="above-sep">·</span>
|
||||||
<span class="above-loc">{entry.location.city}, {entry.location.country}</span>
|
<span class="above-loc">{entry.city}, {entry.countryName}</span>
|
||||||
<span class="above-sep">·</span>
|
<span class="above-sep">·</span>
|
||||||
<span class="above-days">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
<span class="above-days">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +107,7 @@
|
|||||||
onclick={() => (selected = entry)}
|
onclick={() => (selected = entry)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && (selected = entry)}>
|
onkeydown={(e) => e.key === 'Enter' && (selected = entry)}>
|
||||||
|
|
||||||
{#if entry.photos.length > 0}
|
{#if entry.photos?.length > 0}
|
||||||
<div class="gallery">
|
<div class="gallery">
|
||||||
<img class="gallery-main" src={entry.photos[idx]}
|
<img class="gallery-main" src={entry.photos[idx]}
|
||||||
alt="{entry.title} photo {idx + 1}" loading="lazy" />
|
alt="{entry.title} photo {idx + 1}" loading="lazy" />
|
||||||
@@ -124,16 +135,19 @@
|
|||||||
<p class="entry-memo">{entry.memo}</p>
|
<p class="entry-memo">{entry.memo}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="entry-song">
|
<div class="entry-song">
|
||||||
|
{#if entry.transportation && TRANSPORT_LABELS[entry.transportation]}
|
||||||
|
<span class="transport-badge">{TRANSPORT_LABELS[entry.transportation]}</span>
|
||||||
|
{/if}
|
||||||
<svg class="song-icon" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
<svg class="song-icon" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
|
<circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="2"/>
|
||||||
<circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
|
<circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="song-title">{entry.song.title}</span>
|
<span class="song-title">{entry.song?.title || ''}</span>
|
||||||
<span class="song-sep">·</span>
|
<span class="song-sep">·</span>
|
||||||
<span class="song-artist">{entry.song.artist}</span>
|
<span class="song-artist">{entry.song?.artist || ''}</span>
|
||||||
<span class="trip-badge trip-badge--{entry.tripType}">
|
<span class="trip-badge" class:trip-badge--solo={tripType(entry.companions) === 'solo'} class:trip-badge--friends={tripType(entry.companions) === 'friends'}>
|
||||||
{entry.tripType === 'solo' ? 'Solo' : 'With Friends'}
|
{companionText(entry.companions)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +172,6 @@
|
|||||||
font-family: var(--sans, system-ui, sans-serif);
|
font-family: var(--sans, system-ui, sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar */
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -204,7 +217,6 @@
|
|||||||
}
|
}
|
||||||
select:focus { outline: 2px solid var(--accent, #aa3bff); outline-offset: 2px; }
|
select:focus { outline: 2px solid var(--accent, #aa3bff); outline-offset: 2px; }
|
||||||
|
|
||||||
/* Above-card */
|
|
||||||
.above-card {
|
.above-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -216,7 +228,6 @@
|
|||||||
.above-loc, .above-days { font-size: 12px; color: var(--text, #6b6375); }
|
.above-loc, .above-days { font-size: 12px; color: var(--text, #6b6375); }
|
||||||
.above-sep { font-size: 11px; color: var(--border, #c8c6cc); user-select: none; }
|
.above-sep { font-size: 11px; color: var(--border, #c8c6cc); user-select: none; }
|
||||||
|
|
||||||
/* Card */
|
|
||||||
.entry-card {
|
.entry-card {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -236,6 +247,7 @@
|
|||||||
|
|
||||||
.entry-body { padding: 14px 18px 18px; }
|
.entry-body { padding: 14px 18px 18px; }
|
||||||
|
|
||||||
|
.entry-song { gap: 5px; }
|
||||||
.entry-song .trip-badge { margin-left: auto; flex-shrink: 0; }
|
.entry-song .trip-badge { margin-left: auto; flex-shrink: 0; }
|
||||||
|
|
||||||
.trip-badge {
|
.trip-badge {
|
||||||
@@ -248,6 +260,13 @@
|
|||||||
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
|
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
|
||||||
.trip-badge--friends { background: rgba(59,130,246,0.12); color: #1d4ed8; }
|
.trip-badge--friends { background: rgba(59,130,246,0.12); color: #1d4ed8; }
|
||||||
|
|
||||||
|
.transport-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b6375;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-title {
|
.entry-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -276,7 +295,6 @@
|
|||||||
.song-title { font-weight: 500; color: var(--text-h, #08060d); }
|
.song-title { font-weight: 500; color: var(--text-h, #08060d); }
|
||||||
.song-sep { opacity: 0.35; }
|
.song-sep { opacity: 0.35; }
|
||||||
|
|
||||||
/* Gallery */
|
|
||||||
.gallery { position: relative; overflow: hidden; background: #000; }
|
.gallery { position: relative; overflow: hidden; background: #000; }
|
||||||
.gallery-main { width: 100%; height: 220px; object-fit: cover; display: block; }
|
.gallery-main { width: 100%; height: 220px; object-fit: cover; display: block; }
|
||||||
|
|
||||||
@@ -305,7 +323,6 @@
|
|||||||
}
|
}
|
||||||
.gallery-pip.active { background: #fff; transform: scale(1.3); }
|
.gallery-pip.active { background: #fff; transform: scale(1.3); }
|
||||||
|
|
||||||
/* Vertical timeline */
|
|
||||||
.v-list { list-style: none; padding: 0; margin: 0; position: relative; }
|
.v-list { list-style: none; padding: 0; margin: 0; position: relative; }
|
||||||
.v-list::before {
|
.v-list::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -326,7 +343,6 @@
|
|||||||
|
|
||||||
.v-entry-wrap { flex: 1; display: flex; flex-direction: column; }
|
.v-entry-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.page-footer {
|
.page-footer {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
208
src/lib/auth/CountryPicker.svelte
Normal file
208
src/lib/auth/CountryPicker.svelte
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<script>
|
||||||
|
import { getUser, getUserProfile, setHomeCountry } from './userStore.svelte.js';
|
||||||
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
|
||||||
|
let user = $derived(getUser());
|
||||||
|
let profile = $derived(getUserProfile());
|
||||||
|
|
||||||
|
const countries = $derived.by(() => {
|
||||||
|
if (!worldData?.objects?.countries?.geometries) return [];
|
||||||
|
return worldData.objects.countries.geometries
|
||||||
|
.map(g => ({ name: g.properties?.name, code: g.id }))
|
||||||
|
.filter(c => c.name && c.code)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
let selectedCountry = $state(null);
|
||||||
|
|
||||||
|
let filtered = $derived(
|
||||||
|
search
|
||||||
|
? countries.filter(c => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: countries
|
||||||
|
);
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
function select(c) {
|
||||||
|
selectedCountry = c;
|
||||||
|
search = c.name;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (selectedCountry) {
|
||||||
|
setHomeCountry(selectedCountry.name, selectedCountry.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && selectedCountry) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="card">
|
||||||
|
<h1 class="heading">Welcome, {profile?.displayName || 'Traveler'}!</h1>
|
||||||
|
<p class="subtitle">Select your home country to get started</p>
|
||||||
|
|
||||||
|
<div class="dropdown" class:open>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a country..."
|
||||||
|
bind:value={search}
|
||||||
|
onfocus={() => { open = true; }}
|
||||||
|
oninput={() => { open = true; selectedCountry = null; }}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
{#if open}
|
||||||
|
<ul class="list" role="listbox">
|
||||||
|
{#each filtered as country}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedCountry?.name === country.name}
|
||||||
|
class:selected={selectedCountry?.name === country.name}
|
||||||
|
onclick={() => select(country)}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') select(country); }}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{country.name}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<li class="no-results">No countries found</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="continue-btn"
|
||||||
|
disabled={!selectedCountry}
|
||||||
|
onclick={handleSubmit}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1e2937;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font: 700 24px/1.3 sans-serif;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font: 400 15px/1.4 sans-serif;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font: 400 15px/1.4 sans-serif;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
list-style: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font: 400 14px/1.4 sans-serif;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li:hover,
|
||||||
|
.list li.selected {
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
color: #64748b;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
font: 600 16px/1.4 sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-btn:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
src/lib/auth/LoginOverlay.svelte
Normal file
87
src/lib/auth/LoginOverlay.svelte
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script>
|
||||||
|
import { signInWithGoogle } from './userStore.svelte.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="card">
|
||||||
|
<img src="/logo.png" alt="Map Journal" class="logo" />
|
||||||
|
<h1 class="title">Map Journal</h1>
|
||||||
|
<p class="subtitle">Sign in to start your journey</p>
|
||||||
|
<button class="google-btn" onclick={signInWithGoogle}>
|
||||||
|
<svg class="google-icon" viewBox="0 0 48 48">
|
||||||
|
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||||
|
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||||
|
<path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 0 1 9.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 0 0 0 24c0 3.77.87 7.35 2.56 10.78l7.97-6.19z"/>
|
||||||
|
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||||
|
<path fill="none" d="M0 0h48v48H0z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1e2937;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font: 700 28px/1.2 sans-serif;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font: 400 15px/1.4 sans-serif;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #334155;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font: 500 16px/1.4 sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-btn:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/lib/auth/userStore.svelte.js
Normal file
73
src/lib/auth/userStore.svelte.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { auth, db, googleProvider } from '../firebase.js';
|
||||||
|
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
|
||||||
|
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
|
||||||
|
import { initSelectionListener } from '../layout/selection.svelte.js';
|
||||||
|
import { initEntriesListener } from '../stores/entriesStore.svelte.js';
|
||||||
|
|
||||||
|
let _initialized = false;
|
||||||
|
|
||||||
|
let user = $state(null);
|
||||||
|
let userProfile = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let needsCountry = $state(false);
|
||||||
|
|
||||||
|
export function getUser() { return user; }
|
||||||
|
export function getUserProfile() { return userProfile; }
|
||||||
|
export function getLoading() { return loading; }
|
||||||
|
export function getNeedsCountry() { return needsCountry; }
|
||||||
|
|
||||||
|
export async function signInWithGoogle() {
|
||||||
|
await signInWithPopup(auth, googleProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
|
await fbSignOut(auth);
|
||||||
|
user = null;
|
||||||
|
userProfile = null;
|
||||||
|
needsCountry = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setHomeCountry(name, code) {
|
||||||
|
if (!user) return;
|
||||||
|
await setDoc(doc(db, 'users', user.uid), {
|
||||||
|
displayName: user.displayName,
|
||||||
|
photoURL: user.photoURL,
|
||||||
|
email: user.email,
|
||||||
|
homeCountry: name,
|
||||||
|
homeCountryCode: code,
|
||||||
|
visitedCountries: [code],
|
||||||
|
createdAt: serverTimestamp(),
|
||||||
|
});
|
||||||
|
userProfile = { ...userProfile, homeCountry: name, homeCountryCode: code, visitedCountries: [code] };
|
||||||
|
needsCountry = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAuth() {
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
onAuthStateChanged(auth, async (fbUser) => {
|
||||||
|
if (fbUser) {
|
||||||
|
user = fbUser;
|
||||||
|
initSelectionListener(fbUser.uid);
|
||||||
|
initEntriesListener(fbUser.uid);
|
||||||
|
const docRef = doc(db, 'users', fbUser.uid);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
userProfile = docSnap.data();
|
||||||
|
needsCountry = false;
|
||||||
|
} else {
|
||||||
|
userProfile = {
|
||||||
|
displayName: fbUser.displayName,
|
||||||
|
photoURL: fbUser.photoURL,
|
||||||
|
email: fbUser.email,
|
||||||
|
};
|
||||||
|
needsCountry = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user = null;
|
||||||
|
userProfile = null;
|
||||||
|
needsCountry = false;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
18
src/lib/firebase.js
Normal file
18
src/lib/firebase.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { initializeApp } from "firebase/app";
|
||||||
|
import { getAuth, GoogleAuthProvider } from "firebase/auth";
|
||||||
|
import { getFirestore } from "firebase/firestore";
|
||||||
|
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||||
|
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||||
|
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
|
||||||
|
export const auth = getAuth(app);
|
||||||
|
export const db = getFirestore(app);
|
||||||
|
export const googleProvider = new GoogleAuthProvider();
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
|
||||||
|
|
||||||
let { screen, onNavigate } = $props();
|
let { screen, onNavigate } = $props();
|
||||||
|
|
||||||
|
let user = $derived(getUser());
|
||||||
|
let profile = $derived(getUserProfile());
|
||||||
|
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
menuOpen = !menuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignOut() {
|
||||||
|
menuOpen = false;
|
||||||
|
signOut();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
@@ -20,8 +36,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<img src="/profile.jpg" alt="Profile" class="avatar" />
|
{#if user}
|
||||||
|
<div class="avatar-wrapper">
|
||||||
|
<button class="avatar-btn" onclick={toggleMenu} onkeydown={(e) => { if (e.key === 'Enter') toggleMenu(); }}>
|
||||||
|
<img
|
||||||
|
src={user.photoURL || '/profile.jpg'}
|
||||||
|
alt="Profile"
|
||||||
|
class="avatar"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<div class="menu-header">
|
||||||
|
<span class="menu-name">{profile?.displayName || user.displayName}</span>
|
||||||
|
<span class="menu-email">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button class="menu-item" onclick={handleSignOut}>Sign out</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if menuOpen}
|
||||||
|
<button class="backdrop" aria-label="Close menu" onclick={() => { menuOpen = false; }}></button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -101,12 +141,85 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-btn {
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 45px;
|
width: 45px;
|
||||||
height: 45px;
|
height: 45px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
background: #1e2937;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-name {
|
||||||
|
font: 600 14px/1.3 sans-serif;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-email {
|
||||||
|
font: 400 12px/1.3 sans-serif;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #334155;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
font: 400 14px/1.4 sans-serif;
|
||||||
|
color: #fca5a5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,48 @@
|
|||||||
|
import { db } from '../firebase.js';
|
||||||
|
import { doc, onSnapshot, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
|
||||||
|
|
||||||
let selected = $state(new Set());
|
let selected = $state(new Set());
|
||||||
let totalCountries = $state(0);
|
let totalCountries = $state(0);
|
||||||
|
let homeCountryCode = $state(null);
|
||||||
|
let _uid = null;
|
||||||
|
let _unsubscribe = null;
|
||||||
|
|
||||||
|
export function initSelectionListener(uid) {
|
||||||
|
if (_unsubscribe) _unsubscribe();
|
||||||
|
_uid = uid;
|
||||||
|
const userRef = doc(db, 'users', uid);
|
||||||
|
_unsubscribe = onSnapshot(userRef, (snap) => {
|
||||||
|
if (snap.exists()) {
|
||||||
|
const codes = snap.data().visitedCountries || [];
|
||||||
|
selected = new Set(codes);
|
||||||
|
homeCountryCode = snap.data().homeCountryCode || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function toggle(id) {
|
export function toggle(id) {
|
||||||
|
if (!_uid) return;
|
||||||
|
const was = selected.has(id);
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
if (next.has(id)) {
|
if (was) {
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
} else {
|
} else {
|
||||||
next.add(id);
|
next.add(id);
|
||||||
}
|
}
|
||||||
selected = next;
|
selected = next;
|
||||||
|
const userRef = doc(db, 'users', _uid);
|
||||||
|
if (was) {
|
||||||
|
updateDoc(userRef, { visitedCountries: arrayRemove(id) });
|
||||||
|
} else {
|
||||||
|
updateDoc(userRef, { visitedCountries: arrayUnion(id) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAll() {
|
export function clearAll() {
|
||||||
|
if (!_uid) return;
|
||||||
selected = new Set();
|
selected = new Set();
|
||||||
|
const userRef = doc(db, 'users', _uid);
|
||||||
|
updateDoc(userRef, { visitedCountries: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelected() {
|
export function getSelected() {
|
||||||
@@ -26,3 +56,7 @@ export function setTotalCount(n) {
|
|||||||
export function getTotalCount() {
|
export function getTotalCount() {
|
||||||
return totalCountries;
|
return totalCountries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHomeCountryCode() {
|
||||||
|
return homeCountryCode;
|
||||||
|
}
|
||||||
|
|||||||
45
src/lib/stores/entriesStore.svelte.js
Normal file
45
src/lib/stores/entriesStore.svelte.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { db } from '../firebase.js';
|
||||||
|
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
|
||||||
|
|
||||||
|
let entries = $state([]);
|
||||||
|
let _uid = null;
|
||||||
|
let _unsubscribe = null;
|
||||||
|
|
||||||
|
export function getEntries() {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initEntriesListener(uid) {
|
||||||
|
if (_unsubscribe) _unsubscribe();
|
||||||
|
_uid = uid;
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'users', uid, 'entries'),
|
||||||
|
orderBy('createdAt', 'desc')
|
||||||
|
);
|
||||||
|
_unsubscribe = onSnapshot(q, (snap) => {
|
||||||
|
entries = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addEntry(data) {
|
||||||
|
if (!_uid) return null;
|
||||||
|
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
|
||||||
|
...data,
|
||||||
|
createdAt: serverTimestamp(),
|
||||||
|
updatedAt: serverTimestamp(),
|
||||||
|
});
|
||||||
|
return ref.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEntry(id, data) {
|
||||||
|
if (!_uid) return;
|
||||||
|
await updateDoc(doc(db, 'users', _uid, 'entries', id), {
|
||||||
|
...data,
|
||||||
|
updatedAt: serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEntry(id) {
|
||||||
|
if (!_uid) return;
|
||||||
|
await deleteDoc(doc(db, 'users', _uid, 'entries', id));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<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, getTotalCount } from '../layout/selection.svelte.js';
|
||||||
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
|
|
||||||
@@ -13,6 +14,33 @@
|
|||||||
'Oceania': '#a855f7'
|
'Oceania': '#a855f7'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -83,10 +111,20 @@
|
|||||||
<span class="bar-label">by continent</span>
|
<span class="bar-label">by continent</span>
|
||||||
{#each CONTINENTS as continent}
|
{#each CONTINENTS as continent}
|
||||||
{@const contTotal = continentTotals[continent]}
|
{@const contTotal = continentTotals[continent]}
|
||||||
<div class="row">
|
<div class="row tooltip-wrap">
|
||||||
<span class="dot" style="background: {continentColors[continent]}"></span>
|
<span class="dot" style="background: {continentColors[continent]}"></span>
|
||||||
<span class="label">{continent}</span>
|
<span class="label">{continent}</span>
|
||||||
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></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>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -273,12 +311,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-svg {
|
.donut-svg {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-label {
|
.donut-label {
|
||||||
@@ -293,6 +333,40 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-list {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
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 {
|
.disclaimer {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import worldData from 'world-atlas/countries-50m.json';
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
import { getSelected, toggle, setTotalCount } from '../layout/selection.svelte.js';
|
import { getSelected, toggle, setTotalCount, getHomeCountryCode } from '../layout/selection.svelte.js';
|
||||||
|
|
||||||
const TERRITORY_PARENT = {
|
const TERRITORY_PARENT = {
|
||||||
'016': '840', // American Samoa -> United States
|
'016': '840', // American Samoa -> United States
|
||||||
@@ -49,7 +49,30 @@
|
|||||||
return TERRITORY_PARENT[d.id] || d.id;
|
return TERRITORY_PARENT[d.id] || d.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOME_COLOR = '#8b5cf6';
|
||||||
|
const HOME_COLOR_HOVER = '#7c3aed';
|
||||||
|
const VISITED_COLOR = '#22c55e';
|
||||||
|
const VISITED_COLOR_HOVER = '#16a34a';
|
||||||
|
const UNVISITED_COLOR = '#ffffff';
|
||||||
|
const UNVISITED_COLOR_HOVER = '#f0f6fa';
|
||||||
|
|
||||||
|
function countryColor(d, sel, homeCode) {
|
||||||
|
const id = effId(d);
|
||||||
|
if (!sel.has(id)) return UNVISITED_COLOR;
|
||||||
|
if (id === homeCode) return HOME_COLOR;
|
||||||
|
return VISITED_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryHoverColor(d, sel, homeCode) {
|
||||||
|
const id = effId(d);
|
||||||
|
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
|
||||||
|
if (id === homeCode) return HOME_COLOR_HOVER;
|
||||||
|
return VISITED_COLOR_HOVER;
|
||||||
|
}
|
||||||
|
|
||||||
let frameEl;
|
let frameEl;
|
||||||
|
let _paths = null;
|
||||||
|
let _g = null;
|
||||||
|
|
||||||
function fitProjection(proj, w, h) {
|
function fitProjection(proj, w, h) {
|
||||||
proj.fitSize([w, h], { type: 'Sphere' });
|
proj.fitSize([w, h], { type: 'Sphere' });
|
||||||
@@ -57,6 +80,16 @@
|
|||||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateAllFills() {
|
||||||
|
if (!_paths || !_g) return;
|
||||||
|
const sel = getSelected();
|
||||||
|
const hc = getHomeCountryCode();
|
||||||
|
_paths.attr('fill', d => countryColor(d, sel, hc));
|
||||||
|
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(updateAllFills);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const width = frameEl.clientWidth;
|
const width = frameEl.clientWidth;
|
||||||
const height = frameEl.clientHeight;
|
const height = frameEl.clientHeight;
|
||||||
@@ -81,7 +114,7 @@
|
|||||||
.attr('width', width)
|
.attr('width', width)
|
||||||
.attr('height', height);
|
.attr('height', height);
|
||||||
|
|
||||||
const g = svg.append('g');
|
_g = svg.append('g');
|
||||||
|
|
||||||
const tooltip = d3.select(frameEl)
|
const tooltip = d3.select(frameEl)
|
||||||
.append('div')
|
.append('div')
|
||||||
@@ -89,8 +122,10 @@
|
|||||||
.style('display', 'none');
|
.style('display', 'none');
|
||||||
|
|
||||||
function updateFill(sel) {
|
function updateFill(sel) {
|
||||||
sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
|
const s = getSelected();
|
||||||
g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
|
const hc = getHomeCountryCode();
|
||||||
|
sel.attr('fill', d => countryColor(d, s, hc));
|
||||||
|
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, s, hc));
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachEvents(sel) {
|
function attachEvents(sel) {
|
||||||
@@ -100,7 +135,9 @@
|
|||||||
updateFill(d3.select(event.currentTarget));
|
updateFill(d3.select(event.currentTarget));
|
||||||
})
|
})
|
||||||
.on('mouseenter', (event, d) => {
|
.on('mouseenter', (event, d) => {
|
||||||
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');
|
const s = getSelected();
|
||||||
|
const hc = getHomeCountryCode();
|
||||||
|
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
|
||||||
tooltip.style('display', 'block').text(d.properties.name);
|
tooltip.style('display', 'block').text(d.properties.name);
|
||||||
})
|
})
|
||||||
.on('mousemove', (event) => {
|
.on('mousemove', (event) => {
|
||||||
@@ -108,35 +145,38 @@
|
|||||||
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
|
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
|
||||||
})
|
})
|
||||||
.on('mouseleave', (event, d) => {
|
.on('mouseleave', (event, d) => {
|
||||||
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
|
const s = getSelected();
|
||||||
|
const hc = getHomeCountryCode();
|
||||||
|
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
|
||||||
tooltip.style('display', 'none');
|
tooltip.style('display', 'none');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const paths = g.selectAll('path')
|
_paths = _g.selectAll('path')
|
||||||
.data(countries)
|
.data(countries)
|
||||||
.join('path')
|
.join('path')
|
||||||
.attr('d', path)
|
.attr('d', path)
|
||||||
.attr('fill', '#ffffff')
|
.attr('fill', '#ffffff')
|
||||||
.attr('stroke', '#d4d4d4')
|
.attr('stroke', '#d4d4d4')
|
||||||
.attr('stroke-width', 0.5);
|
.attr('stroke-width', 0.5);
|
||||||
attachEvents(paths);
|
attachEvents(_paths);
|
||||||
|
|
||||||
function renderMicrostates() {
|
function renderMicrostates() {
|
||||||
g.selectAll('.micro-state').remove();
|
_g.selectAll('.micro-state').remove();
|
||||||
const threshold = Math.max(4, 16 / d3.zoomTransform(svg.node()).k);
|
const threshold = Math.max(4, 16 / d3.zoomTransform(svg.node()).k);
|
||||||
paths.each(function (d) {
|
_paths.each(function (d) {
|
||||||
if (effId(d) !== d.id) return;
|
if (effId(d) !== d.id) return;
|
||||||
const { width, height } = this.getBBox();
|
const { width, height } = this.getBBox();
|
||||||
if (width < threshold && height < threshold) {
|
if (width < threshold && height < threshold) {
|
||||||
const [cx, cy] = path.centroid(d);
|
const [cx, cy] = path.centroid(d);
|
||||||
const c = g.append('circle')
|
const hc = getHomeCountryCode();
|
||||||
|
const c = _g.append('circle')
|
||||||
.attr('class', 'micro-state')
|
.attr('class', 'micro-state')
|
||||||
.datum(d)
|
.datum(d)
|
||||||
.attr('cx', cx)
|
.attr('cx', cx)
|
||||||
.attr('cy', cy)
|
.attr('cy', cy)
|
||||||
.attr('r', 2)
|
.attr('r', 2)
|
||||||
.attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff')
|
.attr('fill', countryColor(d, getSelected(), hc))
|
||||||
.attr('stroke', '#94a3b8')
|
.attr('stroke', '#94a3b8')
|
||||||
.attr('stroke-width', 0.5);
|
.attr('stroke-width', 0.5);
|
||||||
attachEvents(c);
|
attachEvents(c);
|
||||||
@@ -149,7 +189,7 @@
|
|||||||
const zoom = d3.zoom()
|
const zoom = d3.zoom()
|
||||||
.scaleExtent([1, 32])
|
.scaleExtent([1, 32])
|
||||||
.on('zoom', (event) => {
|
.on('zoom', (event) => {
|
||||||
g.attr('transform', event.transform);
|
_g.attr('transform', event.transform);
|
||||||
renderMicrostates();
|
renderMicrostates();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,7 +206,7 @@
|
|||||||
const { width, height } = entry.contentRect;
|
const { width, height } = entry.contentRect;
|
||||||
svg.attr('width', width).attr('height', height);
|
svg.attr('width', width).attr('height', height);
|
||||||
fitProjection(projection, width, height);
|
fitProjection(projection, width, height);
|
||||||
const countryPaths = g.selectAll('path');
|
const countryPaths = _g.selectAll('path');
|
||||||
countryPaths.attr('d', path);
|
countryPaths.attr('d', path);
|
||||||
updateFill(countryPaths);
|
updateFill(countryPaths);
|
||||||
renderMicrostates();
|
renderMicrostates();
|
||||||
|
|||||||
Reference in New Issue
Block a user