add firebase and sign up

This commit is contained in:
2026-06-12 18:19:45 +09:00
parent cd682f738a
commit 08d3e3ae56
11 changed files with 1667 additions and 43 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
.env
*.local
# Editor directories and files

1047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@
},
"dependencies": {
"d3": "^7.9.0",
"firebase": "^12.14.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
}

View File

@@ -1,4 +1,7 @@
<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 WorldMap from './lib/world-map/WorldMap.svelte';
import StatsPanel from './lib/world-map/StatsPanel.svelte';
@@ -9,22 +12,54 @@
function onNavigate(s) {
screen = s;
}
$effect(() => {
initAuth();
});
let loading = $derived(getLoading());
let user = $derived(getUser());
let needsCountry = $derived(getNeedsCountry());
</script>
<Layout {screen} {onNavigate}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area">
<WorldMap />
{#if loading}
<div class="loading-screen">
<span class="loading-text">Loading...</span>
</div>
{:else}
<Layout {screen} {onNavigate}>
{#if screen === 'worldmap'}
<div class="worldmap-page">
<div class="map-area"><WorldMap /></div>
<StatsPanel />
</div>
<StatsPanel />
</div>
{:else}
<TimelineView />
{:else}
<TimelineView />
{/if}
</Layout>
{#if !user}
<LoginOverlay />
{:else if needsCountry}
<CountryPicker />
{/if}
</Layout>
{/if}
<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 {
display: flex;
flex-direction: row;

View File

@@ -1,20 +1,14 @@
<script>
import { get } from 'svelte/store';
import { journals } from './stores/journalStore.js';
import { getEntries } from './stores/entriesStore.svelte.js';
import JournalDetail from './JournalDetail.svelte';
/** @type {import('./stores/journalStore.js').JournalEntry|null} */
/** @type {Record<string, number>} */
let selected = $state(null);
/** @type {Record<string, number>} */
let photoIdx = $state({});
// Store subscription
let entries = $state(get(journals));
$effect(() => {
const unsub = journals.subscribe((v) => { entries = v; });
return unsub;
});
let entries = $derived(getEntries());
const sortOptions = [
{ value: 'date-desc', label: 'Newest First' },
@@ -25,15 +19,13 @@
let sortKey = $state('date-desc');
// Explicit $effect so sortKey changes always trigger a re-sort
let sortedEntries = $state(/** @type {typeof entries} */([]));
$effect(() => {
let sortedEntries = $derived.by(() => {
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-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-desc') return b.location.country.localeCompare(a.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.countryName || '').localeCompare(a.countryName || '') || b.date.localeCompare(a.date);
return 0;
});
});
@@ -54,6 +46,25 @@
e.stopPropagation();
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>
{#if selected}
@@ -88,7 +99,7 @@
<div class="above-card">
<time class="above-date" datetime={entry.date}>{formatDate(entry.date)}</time>
<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-days">{entry.days} {entry.days === 1 ? 'day' : 'days'}</span>
</div>
@@ -96,7 +107,7 @@
onclick={() => (selected = entry)}
onkeydown={(e) => e.key === 'Enter' && (selected = entry)}>
{#if entry.photos.length > 0}
{#if entry.photos?.length > 0}
<div class="gallery">
<img class="gallery-main" src={entry.photos[idx]}
alt="{entry.title} photo {idx + 1}" loading="lazy" />
@@ -124,16 +135,19 @@
<p class="entry-memo">{entry.memo}</p>
{/if}
<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">
<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="18" cy="16" r="3" stroke="currentColor" stroke-width="2"/>
</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-artist">{entry.song.artist}</span>
<span class="trip-badge trip-badge--{entry.tripType}">
{entry.tripType === 'solo' ? 'Solo' : 'With Friends'}
<span class="song-artist">{entry.song?.artist || ''}</span>
<span class="trip-badge" class:trip-badge--solo={tripType(entry.companions) === 'solo'} class:trip-badge--friends={tripType(entry.companions) === 'friends'}>
{companionText(entry.companions)}
</span>
</div>
</div>
@@ -158,7 +172,6 @@
font-family: var(--sans, system-ui, sans-serif);
}
/* Toolbar */
.toolbar {
display: flex;
align-items: flex-end;
@@ -204,7 +217,6 @@
}
select:focus { outline: 2px solid var(--accent, #aa3bff); outline-offset: 2px; }
/* Above-card */
.above-card {
display: flex;
align-items: center;
@@ -216,7 +228,6 @@
.above-loc, .above-days { font-size: 12px; color: var(--text, #6b6375); }
.above-sep { font-size: 11px; color: var(--border, #c8c6cc); user-select: none; }
/* Card */
.entry-card {
display: block;
width: 100%;
@@ -236,6 +247,7 @@
.entry-body { padding: 14px 18px 18px; }
.entry-song { gap: 5px; }
.entry-song .trip-badge { margin-left: auto; flex-shrink: 0; }
.trip-badge {
@@ -248,6 +260,13 @@
.trip-badge--solo { background: rgba(245,158,11,0.12); color: #b45309; }
.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 {
font-size: 16px;
font-weight: 600;
@@ -276,7 +295,6 @@
.song-title { font-weight: 500; color: var(--text-h, #08060d); }
.song-sep { opacity: 0.35; }
/* Gallery */
.gallery { position: relative; overflow: hidden; background: #000; }
.gallery-main { width: 100%; height: 220px; object-fit: cover; display: block; }
@@ -305,7 +323,6 @@
}
.gallery-pip.active { background: #fff; transform: scale(1.3); }
/* Vertical timeline */
.v-list { list-style: none; padding: 0; margin: 0; position: relative; }
.v-list::before {
content: '';
@@ -326,7 +343,6 @@
.v-entry-wrap { flex: 1; display: flex; flex-direction: column; }
/* Footer */
.page-footer {
margin-top: 40px;
text-align: center;

View File

@@ -0,0 +1,212 @@
<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 [];
const names = worldData.objects.countries.geometries
.map(g => g.properties?.name)
.filter(Boolean);
return [...new Set(names)].sort();
});
let search = $state('');
let selectedCountry = $state('');
let filtered = $derived(
search
? countries.filter(c => c.toLowerCase().includes(search.toLowerCase()))
: countries
);
let open = $state(false);
function toggleDropdown() {
open = !open;
}
function select(c) {
selectedCountry = c;
search = c;
open = false;
}
function handleSubmit() {
if (selectedCountry) {
setHomeCountry(selectedCountry);
}
}
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 = ''; }}
onkeydown={handleKeydown}
class="search-input"
/>
{#if open}
<ul class="list" role="listbox">
{#each filtered as country}
<li
role="option"
aria-selected={selectedCountry === country}
class:selected={selectedCountry === country}
onclick={() => select(country)}
onkeydown={(e) => { if (e.key === 'Enter') select(country); }}
tabindex="0"
>
{country}
</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>

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

View File

@@ -0,0 +1,72 @@
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(country) {
if (!user) return;
await setDoc(doc(db, 'users', user.uid), {
displayName: user.displayName,
photoURL: user.photoURL,
email: user.email,
homeCountry: country,
visitedCountries: [],
createdAt: serverTimestamp(),
});
userProfile = { ...userProfile, homeCountry: country, visitedCountries: [] };
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
View 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();

View File

@@ -1,5 +1,21 @@
<script>
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
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>
<div class="topbar">
@@ -20,10 +36,34 @@
</div>
<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}
<style>
.topbar {
height: 64px;
@@ -101,12 +141,85 @@
align-items: center;
}
.avatar-wrapper {
position: relative;
}
.avatar-btn {
display: flex;
padding: 0;
border: none;
background: none;
cursor: pointer;
border-radius: 50%;
}
.avatar {
width: 45px;
height: 45px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
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: 40;
border: none;
background: transparent;
cursor: default;
}
</style>

View File

@@ -1,18 +1,46 @@
import { db } from '../firebase.js';
import { doc, onSnapshot, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore';
let selected = $state(new Set());
let totalCountries = $state(0);
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);
}
});
}
export function toggle(id) {
if (!_uid) return;
const was = selected.has(id);
const next = new Set(selected);
if (next.has(id)) {
if (was) {
next.delete(id);
} else {
next.add(id);
}
selected = next;
const userRef = doc(db, 'users', _uid);
if (was) {
updateDoc(userRef, { visitedCountries: arrayRemove(id) });
} else {
updateDoc(userRef, { visitedCountries: arrayUnion(id) });
}
}
export function clearAll() {
if (!_uid) return;
selected = new Set();
const userRef = doc(db, 'users', _uid);
updateDoc(userRef, { visitedCountries: [] });
}
export function getSelected() {