feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -11,6 +11,7 @@ import { useEffect, useMemo, useState } from "react";
import type { Sprint, TicketPriority, TicketStatus, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner } from "@/shared";
import { useI18n } from "@/i18n";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
import { SprintManager } from "./SprintManager";
@ -39,6 +40,7 @@ export interface TicketsPanelProps {
}
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const { tr } = useI18n();
const vm = useTickets(projectId);
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
@ -159,10 +161,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
setSelectedRefs(new Set(refs.filter((ref) => failed.has(ref))));
setBulkNotice(
failed.size === 0
? `Sprint mis à jour pour ${successCount} ticket${
successCount > 1 ? "s" : ""
}.`
: `Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`,
? tr(`Sprint mis à jour pour ${successCount} ticket${successCount > 1 ? "s" : ""}.`, `Sprint updated for ${successCount} ticket${successCount > 1 ? "s" : ""}.`)
: tr(`Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`, `Sprint updated for ${successCount}/${result.items.length} tickets. Failed items remain selected.`),
);
}
@ -177,7 +177,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
return (
<Panel
title="Tickets"
title={tr("Tickets", "Tickets")}
actions={
<div className="flex items-center gap-1">
<Button
@ -185,12 +185,12 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
variant="ghost"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? "Cancel" : "New"}
{showCreate ? tr("Annuler", "Cancel") : tr("Nouveau", "New")}
</Button>
<Button
size="sm"
variant="ghost"
aria-label="manage sprints"
aria-label={tr("Gérer les sprints", "Manage sprints")}
onClick={() => setShowSprints(true)}
>
Sprints
@ -201,7 +201,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onClick={() => void vm.refresh()}
loading={vm.busy}
>
Refresh
{tr("Actualiser", "Refresh")}
</Button>
</div>
}
@ -218,21 +218,20 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
{selectedVisibleRefs.length > 0 && (
<div
role="toolbar"
aria-label="bulk ticket actions"
aria-label={tr("Actions groupées sur les tickets", "Bulk ticket actions")}
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-raised/70 px-2 py-2"
>
<span className="text-xs font-medium text-content">
{selectedVisibleRefs.length} sélectionné
{selectedVisibleRefs.length > 1 ? "s" : ""}
{tr(`${selectedVisibleRefs.length} sélectionné${selectedVisibleRefs.length > 1 ? "s" : ""}`, `${selectedVisibleRefs.length} selected`)}
</span>
<TicketViewportSelect
aria-label="Changer le statut"
aria-label={tr("Changer le statut", "Change status")}
value={bulkStatus}
options={[
{ value: "", label: "Changer le statut" },
{ value: "", label: tr("Changer le statut", "Change status") },
...TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status),
label: statusLabel(status, tr),
})),
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
@ -251,7 +250,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
disabled={vm.busy}
onClick={() => setConfirmBulkDelete(true)}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
<Button
size="sm"
@ -259,7 +258,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
disabled={vm.busy}
onClick={() => setSelectedRefs(new Set())}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
</div>
)}
@ -279,18 +278,18 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
>
<Input
aria-label="new ticket title"
placeholder="Ticket title"
aria-label={tr("Titre du nouveau ticket", "New ticket title")}
placeholder={tr("Titre du ticket", "Ticket title")}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<div className="flex items-center gap-2">
<TicketViewportSelect
aria-label="new ticket priority"
aria-label={tr("Priorité du nouveau ticket", "New ticket priority")}
value={newPriority}
options={TICKET_PRIORITIES.map((priority) => ({
value: priority,
label: priorityLabel(priority),
label: priorityLabel(priority, tr),
}))}
onChange={(next) => setNewPriority(next as TicketPriority)}
/>
@ -299,23 +298,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
size="sm"
disabled={!newTitle.trim() || vm.busy}
>
Create
{tr("Créer", "Create")}
</Button>
</div>
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice
(or "Sans sprint"). */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Sprint</span>
<span className="text-xs text-muted">{tr("Sprint", "Sprint")}</span>
<Button
type="button"
size="sm"
variant="ghost"
aria-label="choose sprint for new ticket"
aria-label={tr("Choisir un sprint pour le nouveau ticket", "Choose sprint for new ticket")}
disabled={vm.busy}
onClick={() => setShowSprintPicker(true)}
className="border border-border"
>
{newSprint ? newSprint.name : "Sans sprint"}
{newSprint ? newSprint.name : tr("Sans sprint", "No sprint")}
</Button>
</div>
</form>
@ -343,10 +342,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
/>
<div className="flex flex-wrap items-center gap-2">
<TicketViewportSelect
aria-label="filter by assignee"
aria-label={tr("Filtrer par agent assigné", "Filter by assignee")}
value={vm.query.assignedAgentId ?? ""}
options={[
{ value: "", label: "All assignees" },
{ value: "", label: tr("Tous les agents assignés", "All assignees") },
...agents.map((agent) => ({
value: agent.id,
label: agent.name,
@ -360,11 +359,11 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}
/>
<TicketViewportSelect
aria-label="filter by creator"
aria-label={tr("Filtrer par créateur", "Filter by creator")}
value={creatorFilterValue(vm.query.createdBy)}
options={[
{ value: "", label: "Tous créateurs" },
{ value: "user", label: "Utilisateur" },
{ value: "", label: tr("Tous les créateurs", "All creators") },
{ value: "user", label: tr("Utilisateur", "User") },
...agents.map((agent) => ({
value: `agent:${agent.id}`,
label: agent.name,
@ -386,10 +385,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
{vm.busy && vm.list === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading tickets…</span>
<span>{tr("Chargement des tickets…", "Loading tickets…")}</span>
</div>
) : items.length === 0 ? (
<p className="text-sm text-muted">No tickets.</p>
<p className="text-sm text-muted">{tr("Aucun ticket.", "No tickets.")}</p>
) : (
<div className="flex flex-col gap-4">
{grouped.map((g) => (
@ -408,7 +407,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
))}
{noSprint.length > 0 && (
<SprintSection
heading="Sans sprint"
heading={tr("Sans sprint", "No sprint")}
count={noSprint.length}
tickets={noSprint}
sprints={vm.sprints}
@ -435,7 +434,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
})
}
>
Load more
{tr("Charger plus", "Load more")}
</Button>
</div>
)}
@ -454,7 +453,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
open={showSprintPicker}
projectId={projectId}
selectedSprintId={newSprint?.id ?? null}
title="Sprint du nouveau ticket"
title={tr("Sprint du nouveau ticket", "Sprint for new ticket")}
onSelect={(sprint) => setNewSprint(sprint)}
onClose={() => setShowSprintPicker(false)}
/>
@ -463,9 +462,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
open={showBulkSprintPicker}
projectId={projectId}
selectedSprintId={null}
title={`Sprint pour ${selectedVisibleRefs.length} ticket${
selectedVisibleRefs.length > 1 ? "s" : ""
}`}
title={tr(`Sprint pour ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`, `Sprint for ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`)}
onSelect={(sprint) => void applyBulkSprint(sprint)}
onClose={() => setShowBulkSprintPicker(false)}
/>
@ -474,34 +471,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression groupée"
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-panel p-4 shadow-lg">
<p className="text-sm font-semibold text-content">
Supprimer {selectedVisibleRefs.length} ticket
{selectedVisibleRefs.length > 1 ? "s" : ""}&nbsp;?
{tr(`Supprimer ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""} ?`, `Delete ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}?`)}
</p>
<p className="mt-1 text-sm text-muted">
Cette action supprimera les tickets sélectionnés du projet.
{tr("Cette action supprimera les tickets sélectionnés du projet.", "This will delete the selected tickets from the project.")}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel bulk delete"
aria-label={tr("Annuler la suppression groupée", "Cancel bulk deletion")}
onClick={() => setConfirmBulkDelete(false)}
>
Annuler
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm bulk delete"
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
loading={vm.busy}
onClick={() => void applyBulkDelete()}
>
Supprimer
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>
@ -537,8 +533,9 @@ function SprintSection({
selectedRefs: Set<string>;
onToggleSelected: (ref: string, selected: boolean) => void;
}) {
const { tr } = useI18n();
return (
<section aria-label={`sprint section ${heading}`}>
<section aria-label={`${tr("Section du sprint", "Sprint section")} ${heading}`}>
<h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted">
{heading}
<span className="rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
@ -551,7 +548,7 @@ function SprintSection({
<div className="flex items-start gap-2">
<input
type="checkbox"
aria-label={`select ticket row ${t.ref}`}
aria-label={tr(`Sélectionner le ticket ${t.ref}`, `Select ticket ${t.ref}`)}
checked={selectedRefs.has(t.ref)}
onChange={(e) => onToggleSelected(t.ref, e.currentTarget.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded border-border bg-panel text-primary focus-visible:outline-none"
@ -571,7 +568,7 @@ function SprintSection({
</span>
)}
<span className="mt-0.5 block truncate text-xs text-muted">
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
{tr("Créé par", "Created by")}: {ticketActorLabel(t.createdBy, nameOf)}
</span>
</button>
<span className="flex shrink-0 flex-col items-end gap-1">
@ -581,10 +578,10 @@ function SprintSection({
</div>
<div className="mt-1 pl-6">
<TicketViewportSelect
aria-label={`sprint for ${t.ref}`}
aria-label={tr(`Sprint de ${t.ref}`, `Sprint for ${t.ref}`)}
value={t.sprintId ?? ""}
options={[
{ value: "", label: "— No sprint —" },
{ value: "", label: tr("— Sans sprint —", "— No sprint —") },
...sprints.map((sprint) => ({
value: sprint.id,
label: sprint.name,