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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Prérequis : **Node 20+** et **pnpm**.
git clone https://github.com/csanchez-jetdev/trainquillou.git
cd trainquillou
pnpm install
pnpm dev # http://localhost:3000
pnpm dev # http://localhost:3001
```

Aucune clé d'API, aucun compte, aucun fichier `.env` : l'open data SNCF est ouvert et le
Expand Down Expand Up @@ -170,7 +170,7 @@ format stable. Elles sont utilisables telles quelles si vous auto-hébergez.
exigent `dateTo`.

```bash
curl 'http://localhost:3000/api/search?origin=LYON%20(intramuros)&date=2026-08-14'
curl 'http://localhost:3001/api/search?origin=LYON%20(intramuros)&date=2026-08-14'
```

## Architecture
Expand Down
13 changes: 13 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
/* Fond crème */
--color-cream: #fafaf7;

/*
* Couleurs des deux revendeurs, relevées sur leurs propres pages (styles calculés,
* pas une palette de seconde main) : reconnaître la destination d'un lien sortant
* avant de cliquer vaut mieux que deux liens soulignés identiques.
*
* `trainline` est le vert foncé de leurs propres boutons, pas le vert clair de leur
* logo (#00a88f) : celui-ci ne donne que 3:1 avec du texte blanc, sous le seuil AA.
*/
--color-sncf: #0c131f;
--color-sncf-hover: #242a3e;
--color-trainline: #00705f;
--color-trainline-hover: #00a88f;

--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}

Expand Down
46 changes: 27 additions & 19 deletions app/components/DestinationPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -190,25 +190,33 @@ function formatDate(iso: string): string {
</template>
</div>

<!-- Réservation -->
<div v-if="legSlugs" class="flex items-center gap-3 border-t border-slate-100 bg-slate-50/60 px-3.5 py-2.5 text-xs">
<a
:href="sncfConnectUrl(legSlugs.from, legSlugs.to)"
target="_blank"
rel="noopener"
data-test="popover-book-sncf"
class="font-semibold text-accent-strong underline decoration-accent/40 hover:decoration-accent"
>
Réserver sur SNCF Connect
</a>
<a
:href="trainlineUrl(legSlugs.from, legSlugs.to)"
target="_blank"
rel="noopener"
class="text-rail-soft underline decoration-slate-300 hover:text-rail"
>
Trainline
</a>
<!--
Réservation. Chaque revendeur porte sa propre couleur : on sort du site, autant
savoir où l'on arrive. Deux moitiés égales — `basis-1/2` et non `flex-1`, dont la
base `auto` répartirait selon la longueur du libellé.
-->
<div v-if="legSlugs" class="border-t border-slate-100 bg-slate-50/60 px-3.5 py-2.5">
<p class="text-[11px] font-medium text-rail-soft">Réserver ce trajet</p>
<div class="mt-1.5 flex items-stretch gap-2">
<a
:href="sncfConnectUrl(legSlugs.from, legSlugs.to)"
target="_blank"
rel="noopener"
data-test="popover-book-sncf"
class="flex basis-1/2 items-center justify-center rounded-lg bg-sncf px-3 py-2 text-xs font-semibold text-white transition hover:bg-sncf-hover"
>
SNCF Connect
</a>
<a
:href="trainlineUrl(legSlugs.from, legSlugs.to)"
target="_blank"
rel="noopener"
data-test="popover-book-trainline"
class="flex basis-1/2 items-center justify-center rounded-lg bg-trainline px-3 py-2 text-xs font-semibold text-white transition hover:bg-trainline-hover"
>
Trainline
</a>
</div>
</div>
</div>
</template>
28 changes: 28 additions & 0 deletions app/components/GithubLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
/**
* Lien vers le dépôt. L'adresse est écrite ici et nulle part ailleurs : le projet étant
* sous AGPL-3.0, elle doit rester accessible depuis chaque écran.
*/
defineProps<{
/** Texte à côté de la marque. Absent : lien en icône seule (barre d'application). */
label?: string
}>()

const REPO = 'https://github.com/csanchez-jetdev/trainquillou'
</script>

<template>
<a
:href="REPO"
target="_blank"
rel="noopener"
:title="label ? undefined : 'Code source sur GitHub'"
:aria-label="label ? undefined : 'Code source sur GitHub'"
class="inline-flex items-center gap-1.5"
>
<svg class="h-[1.15em] w-[1.15em] shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 .5C5.73.5.9 5.4.9 11.75c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2.1c-3.2.7-3.87-1.55-3.87-1.55-.52-1.35-1.28-1.71-1.28-1.71-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.21 1.77 1.21 1.03 1.79 2.7 1.27 3.36.97.1-.75.4-1.27.73-1.56-2.55-.29-5.24-1.29-5.24-5.77 0-1.27.44-2.31 1.18-3.13-.12-.29-.51-1.48.11-3.08 0 0 .96-.31 3.15 1.2a10.8 10.8 0 0 1 5.74 0c2.18-1.51 3.14-1.2 3.14-1.2.62 1.6.23 2.79.11 3.08.74.82 1.18 1.86 1.18 3.13 0 4.49-2.7 5.47-5.25 5.76.41.36.78 1.08.78 2.17v3.2c0 .31.2.68.8.56 4.56-1.53 7.84-5.83 7.84-10.91C23.1 5.4 18.27.5 12 .5Z" />
</svg>
<span v-if="label">{{ label }}</span>
</a>
</template>
71 changes: 71 additions & 0 deletions app/components/HeroSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { todayISO, lastBookableISO } from '~~/shared/window'

/**
* Accroche de la page d'accueil : la barre de recherche elle-même, plutôt qu'un bouton
* qui mène à elle. Un champ qui ressemble à une recherche doit chercher — un décor
* cliquable mais impossible à remplir se lit comme une panne.
*
* La saisie n'est pas obligatoire : sans gare, on ouvre l'application avec la date déjà
* posée. Le formulaire n'a donc jamais d'état invalide qui bloque le clic.
*/
const from = ref('')
const date = ref('')
const dateId = useId()

const today = todayISO()
const lastBookable = lastBookableISO()

/**
* La date par défaut est posée au montage : calculée pendant le rendu serveur, ce serait
* celle du serveur (UTC en production), qui désigne encore la veille entre minuit et 2 h
* du matin en France — soit une divergence d'hydratation sur la valeur du champ.
*/
onMounted(() => (date.value = todayISO()))

function submit() {
const origin = from.value.trim()
const query: Record<string, string> = {}
if (origin) query.origin = origin
query.date = date.value || todayISO()
return navigateTo({ path: '/app', query })
}
</script>

<template>
<form
class="w-full max-w-lg rounded-2xl bg-white p-2 shadow-2xl shadow-rail/40"
@submit.prevent="submit"
>
<!-- Même objet que dans l'application : un cadre unique découpé en cellules -->
<div class="rounded-xl border border-slate-200">
<StationInput
v-model="from"
label="Depuis"
placeholder="Paris, Lyon, Nantes…"
test-id="hero-origin"
/>
<div class="mx-3 h-px bg-slate-100" />
<div class="flex items-center gap-2 px-3 py-2.5">
<label :for="dateId" class="w-12 shrink-0 text-xs font-medium text-rail-soft">Date</label>
<input
:id="dateId"
v-model="date"
type="date"
data-test="hero-date"
:min="today"
:max="lastBookable"
class="min-w-0 flex-1 bg-transparent text-[15px] font-medium text-rail outline-none"
>
</div>
</div>

<button
type="submit"
data-test="hero-submit"
class="mt-2 w-full rounded-xl bg-coral px-4 py-3 text-base font-semibold text-white shadow-lg shadow-coral/30 transition hover:bg-coral-strong"
>
Voir les destinations →
</button>
</form>
</template>
46 changes: 44 additions & 2 deletions app/components/MapView.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const TINTS: Array<{ id: string; prop: string; color: string }> = [

const instance = getCurrentInstance()
let map: maplibregl.Map | null = null
let sizeObserver: ResizeObserver | null = null
/**
* Le style est chargé et les couches peuvent être ajoutées. On ne peut pas se fier à
* `map.loaded()` : il renvoie `false` pendant une animation de caméra, et un
Expand Down Expand Up @@ -160,7 +161,28 @@ onMounted(async () => {
// L'attribution (OpenFreeMap, OpenMapTiles, OpenStreetMap) vient du TileJSON de
// la source : la déclarer ici la ferait apparaître en double.
attributionControl: { compact: true },
// Le suivi de taille est fait ici, voir plus bas.
trackResize: false,
})

/**
* Suivi de la taille du conteneur, à la place de celui de MapLibre. Le sien écarte sa
* première notification — celle que `ResizeObserver` émet toujours à l'abonnement —
* pour ne pas refaire le calcul du constructeur. Mais si la mise en page bouge dans la
* même frame que la création de la carte, les deux tailles se fondent en une seule
* notification, celle-ci est écartée, et le canevas reste figé à sa taille de départ
* pour de bon. C'est le cas ici dès qu'une recherche aboutit : elle replie le
* formulaire, la ligne de la carte grandit d'autant, et la carte s'arrêtait aux deux
* tiers de sa ligne sur mobile.
*/
let lastSize = ''
sizeObserver = new ResizeObserver(() => {
const size = `${container.clientWidth}×${container.clientHeight}`
if (size === lastSize) return
lastSize = size
map?.resize()
})
sizeObserver.observe(container)
// Render initial state once map tiles are ready
map.once('load', () => {
styleReady = true
Expand All @@ -177,6 +199,8 @@ onMounted(async () => {
})

onBeforeUnmount(() => {
sizeObserver?.disconnect()
sizeObserver = null
map?.remove()
map = null
})
Expand All @@ -192,6 +216,24 @@ function removeLayerSource(id: string) {
if (map.getSource(id)) map.removeSource(id)
}

/**
* Marge du cadrage, en pixels. Elle était fixée à 400 px à gauche, du temps où la carte
* passait en pleine largeur sous la colonne de recherche ; celle-ci occupe désormais sa
* propre colonne de grille et cette marge ne faisait plus que tasser les points contre le
* bord droit. Sur un téléphone elle était fatale : 400 px de marge dans 393 px de large
* donnent une largeur utile négative, et le cadrage sortait la moitié des marqueurs du
* cadre — dont la gare de départ.
*
* Donc une fraction du conteneur, plafonnée : une marge doit aérer le cadrage, pas le
* manger, et elle ne peut jamais dépasser ce qu'elle borde.
*/
function fitPadding(): maplibregl.PaddingOptions {
const el = map?.getContainer()
const x = Math.min(60, Math.round((el?.clientWidth ?? 480) / 8))
const y = Math.min(60, Math.round((el?.clientHeight ?? 480) / 8))
return { top: y, bottom: y, left: x, right: x }
}

/** Crée un élément DOM de marqueur (pastille colorée). */
function dot(color: string, size: number): HTMLDivElement {
const el = document.createElement('div')
Expand Down Expand Up @@ -245,7 +287,7 @@ function renderRoute(route: RouteResult, selected: number) {
if (pts.length > 1) {
const bounds = new maplibregl.LngLatBounds()
pts.forEach((p) => bounds.extend([p[1], p[0]]))
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 400, right: 60 }, maxZoom: 8, duration: 800 })
map.fitBounds(bounds, { padding: fitPadding(), maxZoom: 8, duration: 800 })
}
}

Expand Down Expand Up @@ -328,7 +370,7 @@ function render(result: SearchResult | null | undefined) {
if (pts.length > 1) {
const b = new maplibregl.LngLatBounds()
pts.forEach((p) => b.extend([p[1], p[0]]))
map.fitBounds(b, { padding: { top: 60, bottom: 60, left: 400, right: 60 }, maxZoom: 8, duration: 800 })
map.fitBounds(b, { padding: fitPadding(), maxZoom: 8, duration: 800 })
}
}

Expand Down
7 changes: 5 additions & 2 deletions app/components/ResultsRail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,11 @@ const CHIP_OFF = 'border-slate-200 bg-white text-rail-soft hover:border-slate-30
</div>

<!-- Pleine largeur, séparées par des filets : 74 rectangles bordés faisaient une
échelle. La marge négative annule le padding de la colonne. -->
<ul v-if="visible.length" class="-mx-3 divide-y divide-slate-100 overflow-auto border-t border-slate-100">
échelle. La marge négative annule le padding de la colonne.
`pb-14` sur mobile : de quoi faire défiler la dernière destination au-dessus de
la bascule carte / liste, qui flotte par-dessus le bas de la liste. Du défilement
en plus, pas de la hauteur utile en moins. -->
<ul v-if="visible.length" class="-mx-3 divide-y divide-slate-100 overflow-auto border-t border-slate-100 pb-14 md:pb-0">
<DestinationCard
v-for="d in visible"
:key="d.label"
Expand Down
4 changes: 3 additions & 1 deletion app/components/RoutePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ function fmtDay(iso: string): string {
Exploration partielle (budget d'appels atteint) : d'autres trajets à 2 correspondances peuvent exister.
</p>

<ul v-if="route.itineraries.length" class="flex flex-col gap-2 overflow-auto pr-1">
<!-- `pb-14` sur mobile : de quoi faire défiler le dernier itinéraire au-dessus de la
bascule carte / liste, qui flotte par-dessus le bas de la liste. -->
<ul v-if="route.itineraries.length" class="flex flex-col gap-2 overflow-auto pr-1 pb-14 md:pb-0">
<li
v-for="(it, i) in route.itineraries"
:key="i"
Expand Down
Loading
Loading