Skip to content

Commit 5050dcb

Browse files
committed
girouette customization feature
1 parent f1e002c commit 5050dcb

20 files changed

Lines changed: 1589 additions & 31 deletions

File tree

apps/client/messages/en.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,63 @@
286286
"vehicle_action_unarchive_title": "Unarchive this vehicle",
287287
"line_assignments_empty": "No vehicle was observed on this line on this date.",
288288
"line_assignments_since": "since",
289+
"line_girouettes_breadcrumb": "Destination boards",
290+
"line_girouettes_column_actions": "Actions",
291+
"line_girouettes_column_destination": "Destination",
292+
"line_girouettes_column_direction": "Direction",
293+
"line_girouettes_column_preview": "Preview",
294+
"line_girouettes_create": "Create destination board",
295+
"line_girouettes_create_success": "Destination board created successfully.",
296+
"line_girouettes_delete": "Delete",
297+
"line_girouettes_delete_confirm": "Are you sure you want to delete this destination board?",
298+
"line_girouettes_delete_success": "Destination board deleted successfully.",
299+
"line_girouettes_direction_any": "All directions",
300+
"line_girouettes_direction_inbound": "Inbound",
301+
"line_girouettes_direction_outbound": "Outbound",
302+
"line_girouettes_disable": "Disable",
303+
"line_girouettes_edit": "Edit",
304+
"line_girouettes_enable": "Enable",
305+
"line_girouettes_empty": "No custom destination boards for this line.",
306+
"line_girouettes_error": "An error occurred.",
307+
"line_girouettes_form_bg_color_label": "Background color",
308+
"line_girouettes_form_cancel": "Cancel",
309+
"line_girouettes_form_create_title": "Create destination board",
310+
"line_girouettes_form_destination_label": "Destination",
311+
"line_girouettes_form_destination_placeholder": "E.g. Central Station",
312+
"line_girouettes_form_destination_any": "Any destination",
313+
"line_girouettes_form_direction_any": "All directions",
314+
"line_girouettes_form_direction_inbound": "Inbound (1)",
315+
"line_girouettes_form_direction_label": "Direction",
316+
"line_girouettes_form_direction_outbound": "Outbound (0)",
317+
"line_girouettes_form_edit_title": "Edit destination board",
318+
"line_girouettes_form_font_family_label": "Font family",
319+
"line_girouettes_form_font_variant_label": "Variant",
320+
"line_girouettes_form_led_color_label": "LED color",
321+
"line_girouettes_form_led_white": "White",
322+
"line_girouettes_form_led_yellow": "Yellow",
323+
"line_girouettes_form_outline_color_label": "Outline color",
324+
"line_girouettes_form_page_add": "Add page",
325+
"line_girouettes_form_page_n": "Page {n}",
326+
"line_girouettes_form_page_remove": "Remove",
327+
"line_girouettes_form_page_text_label": "Text",
328+
"line_girouettes_form_pages_title": "Destination pages",
329+
"line_girouettes_form_preview_title": "Preview",
330+
"line_girouettes_form_route_number_text_label": "Text",
331+
"line_girouettes_form_route_number_title": "Route number",
332+
"line_girouettes_form_save": "Save",
333+
"line_girouettes_form_scroll_label": "Auto-scroll",
334+
"line_girouettes_form_half_pattern_label": "Split pattern",
335+
"line_girouettes_form_half_pattern_none": "None",
336+
"line_girouettes_form_half_pattern_tl": "↖ Top left",
337+
"line_girouettes_form_half_pattern_tr": "↗ Top right",
338+
"line_girouettes_form_half_pattern_bl": "↙ Bottom left",
339+
"line_girouettes_form_half_pattern_br": "↘ Bottom right",
340+
"line_girouettes_form_text_color_label": "Text color",
341+
"line_girouettes_page_title": "Destination boards – {lineNumber} – {networkName} – Bus Tracker",
342+
"line_girouettes_title": "Destination boards for line {lineNumber}",
343+
"line_girouettes_update_success": "Destination board updated successfully.",
344+
"network_lines_assignments_action": "Vehicle assignments",
345+
"network_lines_girouettes_action": "Manage destination boards",
289346
"legal_page_title": "Legal notice – Bus Tracker",
290347
"legal_title": "Legal notice & Privacy policy",
291348
"legal_updated_at": "Last updated: March 2026",

apps/client/messages/fr.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,63 @@
286286
"vehicle_action_unarchive_title": "Désarchiver ce véhicule",
287287
"line_assignments_empty": "Aucun véhicule n'a été observé sur cette ligne à cette date.",
288288
"line_assignments_since": "depuis",
289+
"line_girouettes_breadcrumb": "Girouettes",
290+
"line_girouettes_column_actions": "Actions",
291+
"line_girouettes_column_destination": "Destination",
292+
"line_girouettes_column_direction": "Direction",
293+
"line_girouettes_column_preview": "Prévisualisation",
294+
"line_girouettes_create": "Créer une girouette",
295+
"line_girouettes_create_success": "Girouette créée avec succès.",
296+
"line_girouettes_delete": "Supprimer",
297+
"line_girouettes_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette girouette ?",
298+
"line_girouettes_delete_success": "Girouette supprimée avec succès.",
299+
"line_girouettes_direction_any": "Toutes directions",
300+
"line_girouettes_direction_inbound": "Retour",
301+
"line_girouettes_direction_outbound": "Aller",
302+
"line_girouettes_disable": "Désactiver",
303+
"line_girouettes_edit": "Modifier",
304+
"line_girouettes_enable": "Activer",
305+
"line_girouettes_empty": "Aucune girouette personnalisée pour cette ligne.",
306+
"line_girouettes_error": "Une erreur est survenue.",
307+
"line_girouettes_form_bg_color_label": "Couleur de fond",
308+
"line_girouettes_form_cancel": "Annuler",
309+
"line_girouettes_form_create_title": "Créer une girouette",
310+
"line_girouettes_form_destination_label": "Destination",
311+
"line_girouettes_form_destination_placeholder": "Ex : Gare SNCF",
312+
"line_girouettes_form_destination_any": "Toutes destinations",
313+
"line_girouettes_form_direction_any": "Toutes directions",
314+
"line_girouettes_form_direction_inbound": "Retour (1)",
315+
"line_girouettes_form_direction_label": "Direction",
316+
"line_girouettes_form_direction_outbound": "Aller (0)",
317+
"line_girouettes_form_edit_title": "Modifier la girouette",
318+
"line_girouettes_form_font_family_label": "Famille de police",
319+
"line_girouettes_form_font_variant_label": "Variante",
320+
"line_girouettes_form_led_color_label": "Couleur LED",
321+
"line_girouettes_form_led_white": "Blanc",
322+
"line_girouettes_form_led_yellow": "Jaune",
323+
"line_girouettes_form_outline_color_label": "Couleur de contour",
324+
"line_girouettes_form_page_add": "Ajouter une page",
325+
"line_girouettes_form_page_n": "Page {n}",
326+
"line_girouettes_form_page_remove": "Supprimer",
327+
"line_girouettes_form_page_text_label": "Texte",
328+
"line_girouettes_form_pages_title": "Pages de destination",
329+
"line_girouettes_form_preview_title": "Prévisualisation",
330+
"line_girouettes_form_route_number_text_label": "Texte",
331+
"line_girouettes_form_route_number_title": "Numéro de ligne",
332+
"line_girouettes_form_save": "Sauvegarder",
333+
"line_girouettes_form_scroll_label": "Défilement automatique",
334+
"line_girouettes_form_half_pattern_label": "Motif split",
335+
"line_girouettes_form_half_pattern_none": "Aucun",
336+
"line_girouettes_form_half_pattern_tl": "↖ Haut gauche",
337+
"line_girouettes_form_half_pattern_tr": "↗ Haut droite",
338+
"line_girouettes_form_half_pattern_bl": "↙ Bas gauche",
339+
"line_girouettes_form_half_pattern_br": "↘ Bas droite",
340+
"line_girouettes_form_text_color_label": "Couleur du texte",
341+
"line_girouettes_page_title": "Girouettes – {lineNumber} – {networkName} – Bus Tracker",
342+
"line_girouettes_title": "Girouettes de la ligne {lineNumber}",
343+
"line_girouettes_update_success": "Girouette mise à jour avec succès.",
344+
"network_lines_assignments_action": "Affectations de véhicules",
345+
"network_lines_girouettes_action": "Gérer les girouettes",
289346
"legal_page_title": "Mentions légales – Bus Tracker",
290347
"legal_title": "Mentions légales & Politique de confidentialité",
291348
"legal_updated_at": "Dernière mise à jour : Mars 2026",

apps/client/src/api/girouettes.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { mutationOptions, queryOptions } from "@tanstack/react-query";
2+
3+
import { client } from "~/api/client";
4+
import { getLegacyEditorToken } from "~/api/editors";
5+
import type { GirouetteData } from "~/components/vehicles-map/vehicles-markers/popup/girouette";
6+
7+
function getLegacyEditorHeaders() {
8+
const token = getLegacyEditorToken();
9+
return token === null ? undefined : { "X-Editor-Token": token };
10+
}
11+
12+
export type Girouette = {
13+
id: number;
14+
networkId: number;
15+
lineId: number | null;
16+
directionId: number | null;
17+
destinations: string[];
18+
data: GirouetteData;
19+
enabled: boolean;
20+
};
21+
22+
export type GirouetteInput = {
23+
directionId: number | null;
24+
destinations: string[];
25+
data: GirouetteData;
26+
enabled: boolean;
27+
};
28+
29+
export const GetLineGirouettesQuery = (lineId: number) =>
30+
queryOptions({
31+
staleTime: 30_000,
32+
queryKey: ["lines", lineId, "girouettes"],
33+
queryFn: () => client.get(`/lines/${lineId}/girouettes`, { headers: getLegacyEditorHeaders() }).json<Girouette[]>(),
34+
});
35+
36+
export const CreateGirouetteMutation = (lineId: number) =>
37+
mutationOptions({
38+
mutationFn: async (json: GirouetteInput) => {
39+
return client.post(`/lines/${lineId}/girouettes`, { headers: getLegacyEditorHeaders(), json }).json<Girouette>();
40+
},
41+
});
42+
43+
export const UpdateGirouetteMutation = (girouetteId: number) =>
44+
mutationOptions({
45+
mutationFn: async (json: GirouetteInput) => {
46+
return client.put(`/girouettes/${girouetteId}`, { headers: getLegacyEditorHeaders(), json }).json<Girouette>();
47+
},
48+
});
49+
50+
export const ToggleGirouetteEnabledMutation = (girouetteId: number) =>
51+
mutationOptions({
52+
mutationFn: async (enabled: boolean) => {
53+
return client
54+
.patch(`/girouettes/${girouetteId}/enabled`, { headers: getLegacyEditorHeaders(), json: { enabled } })
55+
.json<Girouette>();
56+
},
57+
});
58+
59+
export const DeleteGirouetteMutation = (girouetteId: number) =>
60+
mutationOptions({
61+
mutationFn: async () => {
62+
await client.delete(`/girouettes/${girouetteId}`, { headers: getLegacyEditorHeaders() });
63+
},
64+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { XIcon } from "lucide-react";
2+
import { useRef } from "react";
3+
4+
import { cn } from "~/utils/cn";
5+
6+
type ColorPickerProps = {
7+
value: string;
8+
onChange: (value: string) => void;
9+
placeholder?: string;
10+
className?: string;
11+
};
12+
13+
export function ColorPicker({ value, onChange, placeholder = "#RRGGBB", className }: Readonly<ColorPickerProps>) {
14+
const colorInputRef = useRef<HTMLInputElement>(null);
15+
const isValidHex = /^#[0-9A-Fa-f]{6}$/.test(value);
16+
17+
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
18+
const raw = e.target.value;
19+
onChange(raw.startsWith("#") ? raw : raw ? `#${raw}` : "");
20+
};
21+
22+
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
23+
onChange(e.target.value);
24+
};
25+
26+
return (
27+
<div className={cn("flex items-center gap-1.5", className)}>
28+
<button
29+
type="button"
30+
title="Choisir une couleur"
31+
onClick={() => colorInputRef.current?.click()}
32+
className="size-8 shrink-0 rounded-lg border border-input transition-colors hover:border-ring focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
33+
style={{ backgroundColor: isValidHex ? value : "transparent" }}
34+
>
35+
{!isValidHex && (
36+
<span className="block w-full h-full rounded-lg bg-[repeating-conic-gradient(#ddd_0%_25%,#fff_0%_50%)] bg-[length:8px_8px]" />
37+
)}
38+
</button>
39+
40+
<input
41+
ref={colorInputRef}
42+
type="color"
43+
className="sr-only"
44+
value={isValidHex ? value : "#000000"}
45+
onChange={handleColorInputChange}
46+
tabIndex={-1}
47+
/>
48+
49+
<input
50+
type="text"
51+
value={value}
52+
onChange={handleTextChange}
53+
placeholder={placeholder}
54+
maxLength={7}
55+
spellCheck={false}
56+
className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 font-mono text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive"
57+
/>
58+
59+
<button
60+
aria-disabled={!value}
61+
type="button"
62+
onClick={() => onChange("")}
63+
title="Réinitialiser"
64+
className={cn("shrink-0 text-muted-foreground hover:text-foreground transition-colors", !value && "invisible")}
65+
>
66+
<XIcon className="size-4" />
67+
</button>
68+
</div>
69+
);
70+
}

apps/client/src/components/ui/switch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function Switch({
1414
data-slot="switch"
1515
data-size={size}
1616
className={cn(
17-
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:inset-x-0 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-8 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-branding data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
17+
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:inset-x-0 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-branding data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
1818
className,
1919
)}
2020
{...props}

0 commit comments

Comments
 (0)