Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ src/
MapView.svelte — Leaflet map with markers and walking route polylines
utils.js — pure business logic (haversine, fairness, formatting)
utils.test.js — unit tests for utils.js
i18n.js — translations (FR, EN, ES) and t() helper
```

## Commands
Expand Down Expand Up @@ -71,4 +72,5 @@ This app calls these APIs from the browser (no backend):
- State management uses Svelte 5 runes (`$state`) in `stores.svelte.js`, exported as shared reactive objects.
- Saved groups are persisted in `localStorage` (no database).
- `config.js` is gitignored — copy `config.example.js` and adjust values as needed.
- i18n: all user-facing strings are in `src/lib/i18n.js`. When adding UI text, add keys for all 3 languages (fr, en, es). Locale auto-detected from browser, persisted in `localStorage`.
- ORS API key is optional in config — users can enter their own key in the app UI (settings icon). Key is stored in `sessionStorage` only (cleared on browser close).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ npm run dev
- Walking route visualization on the map with a different color per friend
- Per-friend distance breakdown when selecting a venue
- Save and load groups of addresses (persisted in localStorage)
- Multilingual: French, English, Spanish (auto-detected from browser)
- km / miles toggle
- Configurable max distance between friends and max number of addresses

Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="fr">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📍</text></svg>" />
Expand Down
51 changes: 31 additions & 20 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import MapView from './lib/MapView.svelte';
import GroupManager from './lib/GroupManager.svelte';
import { friends, venues, mode, ranking, unit, apiKey, setApiKey, tooFarApart, MAX_DISTANCE, MAX_ADDRESSES, searchVenues, rerankVenues, formatMaxDistance } from './lib/stores.svelte.js';
import { t, locale, locales, localeLabels, setLocale } from './lib/i18n.js';

let selectedVenue = $state(null);
let sidebarOpen = $state(true);
Expand All @@ -23,11 +24,21 @@
<div class="flex items-center gap-2 sm:gap-3">
<span class="text-2xl sm:text-3xl">📍</span>
<div>
<h1 class="text-lg sm:text-xl font-display font-bold italic text-dark leading-tight">Rendez-vous</h1>
<p class="text-xs text-medium hidden sm:block">Find the perfect meeting spot</p>
<h1 class="text-lg sm:text-xl font-display font-bold italic text-dark leading-tight">{t('app.title')}</h1>
<p class="text-xs text-medium hidden sm:block">{t('app.subtitle')}</p>
</div>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<!-- Language selector -->
<div class="flex bg-warm-gray rounded-lg p-0.5 gap-0.5">
{#each locales as lang}
<button
onclick={() => setLocale(lang)}
class="px-1.5 sm:px-2 py-1 rounded-md text-xs font-semibold transition-all duration-200 cursor-pointer border-none {locale.value === lang ? 'bg-white shadow-sm text-coral' : 'bg-transparent text-medium hover:text-dark'}"
>{localeLabels[lang]}</button>
{/each}
</div>
<!-- Unit selector -->
<div class="flex bg-warm-gray rounded-lg p-0.5 gap-0.5">
<button
onclick={() => unit.value = 'km'}
Expand All @@ -41,7 +52,7 @@
<button
onclick={() => showSettings = !showSettings}
class="px-2 sm:px-2.5 py-1 rounded-lg text-sm cursor-pointer border-none transition-colors {showSettings ? 'bg-coral/10 text-coral' : 'bg-warm-gray text-medium hover:text-dark'}"
title="Settings"
title={t('settings')}
>
{apiKey.value ? '🔑' : '⚙️'}
</button>
Expand All @@ -57,20 +68,20 @@
<!-- Settings bar -->
{#if showSettings}
<div class="px-4 sm:px-6 py-3 bg-warm-gray/50 border-b border-gray-100 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 animate-fade-in">
<label class="text-xs font-semibold text-dark whitespace-nowrap" for="ors-key">ORS API Key</label>
<label class="text-xs font-semibold text-dark whitespace-nowrap" for="ors-key">{t('settings.apiKey')}</label>
<div class="flex items-center gap-2 flex-1">
<input
id="ors-key"
type="password"
bind:value={keyInput}
onchange={() => setApiKey(keyInput.trim())}
placeholder="Paste your OpenRouteService key..."
placeholder={t('settings.apiKeyPlaceholder')}
class="flex-1 bg-white rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-dark placeholder:text-light outline-none focus:border-coral/30 min-w-0"
/>
{#if apiKey.value}
<span class="text-xs text-mint font-semibold shrink-0">Active</span>
<span class="text-xs text-mint font-semibold shrink-0">{t('settings.active')}</span>
{:else}
<a href="https://openrouteservice.org/dev/#/signup" target="_blank" rel="noopener" class="text-xs text-coral hover:underline whitespace-nowrap shrink-0">Get a free key</a>
<a href="https://openrouteservice.org/dev/#/signup" target="_blank" rel="noopener" class="text-xs text-coral hover:underline whitespace-nowrap shrink-0">{t('settings.getKey')}</a>
{/if}
</div>
</div>
Expand All @@ -86,7 +97,7 @@
<!-- Search -->
<div>
<h2 class="text-sm font-bold text-dark mb-2 flex items-center gap-2">
<span>👥</span> Who's coming?
<span>👥</span> {t('friends.title')}
</h2>
<AddressInput />
</div>
Expand All @@ -97,7 +108,7 @@
<!-- Groups -->
<div>
<h2 class="text-sm font-bold text-dark mb-2 flex items-center gap-2">
<span>💾</span> Saved groups
<span>💾</span> {t('groups.title')}
</h2>
<GroupManager />
</div>
Expand All @@ -107,7 +118,7 @@
<div class="flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 animate-fade-in">
<span class="text-lg">⚠️</span>
<p class="text-sm text-amber-800">
Some friends are more than {formatMaxDistance()} apart. Add closer addresses to find a meeting spot.
{t('warning.tooFar', { distance: formatMaxDistance() })}
</p>
</div>
{/if}
Expand All @@ -116,30 +127,30 @@
{#if friends.list.length >= 2 && !tooFarApart.value}
<div class="animate-fade-in">
<h2 class="text-sm font-bold text-dark mb-2 flex items-center gap-2">
<span>🎯</span> What are you looking for?
<span>🎯</span> {t('mode.title')}
</h2>
<ModeToggle />
</div>

<!-- Ranking toggle -->
<div class="animate-fade-in">
<h2 class="text-sm font-bold text-dark mb-2 flex items-center gap-2">
<span>⚖️</span> Rank by
<span>⚖️</span> {t('ranking.title')}
</h2>
<div class="flex bg-warm-gray rounded-xl p-1 gap-1">
<button
onclick={() => { ranking.value = 'equidistant'; rerankVenues(); }}
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-semibold transition-all duration-200 cursor-pointer border-none {ranking.value === 'equidistant' ? 'bg-white shadow-sm text-coral' : 'bg-transparent text-medium hover:text-dark'}"
>
<span class="text-lg">📏</span>
Distance
{t('ranking.distance')}
</button>
<button
onclick={() => { if (!apiKey.value) { showSettings = true; return; } ranking.value = 'walking'; rerankVenues(); }}
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-semibold transition-all duration-200 border-none {!apiKey.value ? 'bg-transparent text-light cursor-default' : ranking.value === 'walking' ? 'bg-white shadow-sm text-coral cursor-pointer' : 'bg-transparent text-medium hover:text-dark cursor-pointer'}"
>
<span class="text-lg">🚶</span>
Walk time
{t('ranking.walkTime')}
{#if !apiKey.value}
<span class="text-[10px] text-light">🔑</span>
{/if}
Expand All @@ -153,9 +164,9 @@
<div class="animate-fade-in">
<h2 class="text-sm font-bold text-dark mb-2 flex items-center gap-2">
<span>{mode.value === 'restaurant' ? '🍽️' : '🍸'}</span>
Best spots
{t('venues.title')}
{#if venues.list.length > 0}
<span class="text-xs font-normal text-medium">({venues.list.length} found)</span>
<span class="text-xs font-normal text-medium">({t('venues.found', { count: venues.list.length })})</span>
{/if}
</h2>
<VenueList onSelectVenue={handleSelectVenue} selectedVenueId={selectedVenue?.id} />
Expand All @@ -166,9 +177,9 @@
{#if friends.list.length === 0}
<div class="flex-1 flex flex-col items-center justify-center text-center py-8 animate-fade-in">
<div class="text-6xl mb-4 animate-bounce-slow">🤝</div>
<h3 class="text-lg font-bold text-dark mb-1">Where shall we meet?</h3>
<h3 class="text-lg font-bold text-dark mb-1">{t('empty.title')}</h3>
<p class="text-sm text-medium max-w-[260px]">
Start by adding your friends' addresses above. We'll find the fairest meeting spot for everyone!
{t('empty.description')}
</p>
</div>
{/if}
Expand All @@ -184,8 +195,8 @@
<div class="absolute top-3 left-3 sm:top-4 sm:left-4 bg-white/90 backdrop-blur-md rounded-xl px-3 sm:px-4 py-1.5 sm:py-2 shadow-lg border border-gray-100 animate-fade-in">
<p class="text-xs text-medium">
<span class="font-bold text-coral">{venues.list.length}</span>
{mode.value === 'restaurant' ? 'restaurants' : 'bars'} found
· ranked by {ranking.value === 'walking' ? 'walk time' : 'distance'}
{mode.value === 'restaurant' ? t('info.restaurants') : t('info.bars')} {t('info.found')}
· {t('info.rankedBy')} {ranking.value === 'walking' ? t('info.walkTime') : t('info.distance')}
</p>
</div>
{/if}
Expand Down
9 changes: 5 additions & 4 deletions src/lib/AddressInput.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import { addFriend, friends, searchVenues, MAX_ADDRESSES } from './stores.svelte.js';
import { t, locale } from './i18n.js';

let query = $state('');
let friendName = $state('');
Expand All @@ -23,7 +24,7 @@
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5&addressdetails=1`,
{ headers: { 'Accept-Language': 'fr,en' } }
{ headers: { 'Accept-Language': `${locale.value},en` } }
);
suggestions = await res.json();
showSuggestions = suggestions.length > 0;
Expand All @@ -36,7 +37,7 @@

function selectSuggestion(s) {
if (friends.list.length >= MAX_ADDRESSES) return;
const name = friendName.trim() || `Ami ${friends.list.length + 1}`;
const name = friendName.trim() || `${friends.list.length + 1}`;
addFriend(name, parseFloat(s.lat), parseFloat(s.lon), s.display_name);
query = '';
friendName = '';
Expand All @@ -63,7 +64,7 @@
type="text"
bind:this={nameInput}
bind:value={friendName}
placeholder="Name (optional)"
placeholder={t('friends.namePlaceholder')}
disabled={friends.list.length >= MAX_ADDRESSES}
class="flex-1 bg-transparent outline-none text-dark placeholder:text-light text-[15px] disabled:opacity-50 disabled:cursor-not-allowed"
/>
Expand All @@ -78,7 +79,7 @@
onkeydown={handleKeydown}
onblur={handleBlur}
onfocus={() => { if (suggestions.length > 0) showSuggestions = true; }}
placeholder={friends.list.length >= MAX_ADDRESSES ? `Max ${MAX_ADDRESSES} addresses reached` : "Address..."}
placeholder={friends.list.length >= MAX_ADDRESSES ? t('friends.maxReached', { max: MAX_ADDRESSES }) : t('friends.addressPlaceholder')}
disabled={friends.list.length >= MAX_ADDRESSES}
class="flex-1 bg-transparent outline-none text-dark placeholder:text-light text-[15px] disabled:opacity-50 disabled:cursor-not-allowed"
/>
Expand Down
3 changes: 2 additions & 1 deletion src/lib/FriendList.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import { friends, removeFriend, searchVenues } from './stores.svelte.js';
import { t } from './i18n.js';

const colors = ['#FF6B6B', '#A8E6CF', '#84C5F4', '#DCD6F7', '#FFD93D', '#FF8CC8', '#6BCB77', '#C4A1FF'];

Expand Down Expand Up @@ -38,7 +39,7 @@
<button
onclick={() => handleRemove(friend.id)}
class="opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-light hover:text-coral cursor-pointer bg-transparent border-none text-lg p-1"
aria-label="Remove {friend.name}"
aria-label={t('friends.remove', { name: friend.name })}
>
</button>
Expand Down
25 changes: 13 additions & 12 deletions src/lib/GroupManager.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script>
import { friends, loadFriends, searchVenues } from './stores.svelte.js';
import { groups, saveGroup, deleteGroup, renameGroup, updateGroup } from './groups.svelte.js';
import { t } from './i18n.js';

let saveName = $state('');
let editingId = $state(null);
Expand Down Expand Up @@ -66,19 +67,19 @@
type="text"
bind:value={saveName}
onkeydown={(e) => { if (e.key === 'Enter') handleSave(); }}
placeholder="Group name..."
placeholder={t('groups.namePlaceholder')}
class="flex-1 bg-white rounded-xl border border-gray-100 px-3 py-2 text-sm text-dark placeholder:text-light outline-none focus:border-coral/30 focus:shadow-sm transition-all"
/>
<button
onclick={handleSave}
disabled={!saveName.trim()}
class="px-4 py-2 rounded-xl bg-coral text-white text-sm font-semibold border-none cursor-pointer hover:bg-coral/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
{t('groups.save')}
</button>
</div>
{#if showSaved}
<p class="text-xs text-mint font-semibold animate-fade-in">Saved!</p>
<p class="text-xs text-mint font-semibold animate-fade-in">{t('groups.saved')}</p>
{/if}
{/if}

Expand All @@ -102,7 +103,7 @@
class="flex-1 text-left bg-transparent border-none cursor-pointer p-0"
>
<p class="text-sm font-semibold text-dark">{group.name}</p>
<p class="text-[11px] text-medium">{group.friends.length} addresses · {group.friends.map(f => f.name).join(', ')}</p>
<p class="text-[11px] text-medium">{group.friends.length} {t('groups.addresses')} · {group.friends.map(f => f.name).join(', ')}</p>
</button>
{/if}
</div>
Expand All @@ -111,32 +112,32 @@
<button
onclick={() => handleLoad(group)}
class="text-[11px] px-2.5 py-1 rounded-lg bg-warm-gray text-dark font-semibold border-none cursor-pointer hover:bg-gray-200 transition-colors"
title="Load this group"
title={t('groups.loadTitle')}
>
Load
{t('groups.load')}
</button>
{#if friends.list.length >= 2}
<button
onclick={() => handleUpdate(group)}
class="text-[11px] px-2.5 py-1 rounded-lg bg-warm-gray text-dark font-semibold border-none cursor-pointer hover:bg-gray-200 transition-colors"
title="Overwrite with current addresses"
title={t('groups.updateTitle')}
>
Update
{t('groups.update')}
</button>
{/if}
<button
onclick={() => startRename(group)}
class="text-[11px] px-2.5 py-1 rounded-lg bg-warm-gray text-dark font-semibold border-none cursor-pointer hover:bg-gray-200 transition-colors"
title="Rename"
title={t('groups.rename')}
>
Rename
{t('groups.rename')}
</button>
<button
onclick={() => handleDelete(group.id)}
class="text-[11px] px-2.5 py-1 rounded-lg font-semibold border-none cursor-pointer transition-colors {confirmDeleteId === group.id ? 'bg-red-500 text-white' : 'bg-warm-gray text-dark hover:bg-red-100 hover:text-red-600'}"
title={confirmDeleteId === group.id ? 'Click again to confirm' : 'Delete'}
title={confirmDeleteId === group.id ? t('groups.confirmTitle') : t('groups.delete')}
>
{confirmDeleteId === group.id ? 'Confirm?' : 'Delete'}
{confirmDeleteId === group.id ? t('groups.confirm') : t('groups.delete')}
</button>
</div>
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/lib/MapView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { friends, venues, centroid, mode, apiKey, fetchRoutes } from './stores.svelte.js';
import { t } from './i18n.js';

let { selectedVenue = $bindable(null) } = $props();

Expand Down Expand Up @@ -121,7 +122,7 @@
if (centroid.lat !== null && friends.list.length >= 2) {
centroidMarker = L.marker([centroid.lat, centroid.lng], { icon: centroidIcon })
.addTo(map)
.bindPopup('<div class="friend-popup">📍 Meeting point</div>');
.bindPopup(`<div class="friend-popup">${t('map.meetingPoint')}</div>`);
}
});

Expand All @@ -138,7 +139,7 @@
<div>
<div class="venue-popup-name">${v.name}</div>
${v.cuisine ? `<div class="venue-popup-type">${v.cuisine.replace(/;/g, ', ')}</div>` : ''}
<div class="venue-popup-distance">~${Math.round(v.avgDistance)}m avg</div>
<div class="venue-popup-distance">~${Math.round(v.avgDistance)}m ${t('map.avg')}</div>
</div>
`);
marker.on('click', () => { selectedVenue = v; });
Expand Down
5 changes: 3 additions & 2 deletions src/lib/ModeToggle.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import { mode, searchVenues, friends } from './stores.svelte.js';
import { t } from './i18n.js';

function toggle(newMode) {
mode.value = newMode;
Expand All @@ -15,13 +16,13 @@
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-semibold transition-all duration-200 cursor-pointer border-none {mode.value === 'restaurant' ? 'bg-white shadow-sm text-coral' : 'bg-transparent text-medium hover:text-dark'}"
>
<span class="text-lg">🍽️</span>
Restaurants
{t('mode.restaurants')}
</button>
<button
onclick={() => toggle('bar')}
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-semibold transition-all duration-200 cursor-pointer border-none {mode.value === 'bar' ? 'bg-white shadow-sm text-coral' : 'bg-transparent text-medium hover:text-dark'}"
>
<span class="text-lg">🍸</span>
Bars
{t('mode.bars')}
</button>
</div>
Loading
Loading