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:
@ -22,6 +22,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
ModelServersPanel,
|
||||
useModelServers,
|
||||
@ -60,6 +61,7 @@ export function FirstRunWizard({
|
||||
/** Render the wizard even when it is no longer the first run. */
|
||||
forceOpen?: boolean;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
// Mode is explicit (ticket #44), driven by the entry point — never inferred
|
||||
// from `isFirstRun`: reopening from Settings (`forceOpen`) is the "edit" mode,
|
||||
// which pre-loads and pre-selects the already-configured profiles.
|
||||
@ -77,14 +79,13 @@ export function FirstRunWizard({
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="first run setup"
|
||||
aria-label={tr("Configuration initiale", "First run setup")}
|
||||
className="border-primary/40"
|
||||
title={
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
|
||||
<h2 className="text-base font-semibold text-content">{tr("Bienvenue dans IdeA", "Welcome to IdeA")}</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Choose which AI CLIs to configure. Commands are pre-filled and
|
||||
editable.
|
||||
{tr("Choisissez les CLI d’IA à configurer. Les commandes sont préremplies et modifiables.", "Choose which AI CLIs to configure. Commands are pre-filled and editable.")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@ -104,11 +105,11 @@ export function FirstRunWizard({
|
||||
disabled={vm.busy}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Detect installed CLIs
|
||||
{tr("Détecter les CLI installées", "Detect installed CLIs")}
|
||||
</Button>
|
||||
{vm.detecting && (
|
||||
<span role="status" className="text-xs text-muted">
|
||||
Detecting…
|
||||
{tr("Détection…", "Detecting…")}
|
||||
</span>
|
||||
)}
|
||||
{/* F36: declare several local OpenCode profiles. Each click clones the
|
||||
@ -118,7 +119,7 @@ export function FirstRunWizard({
|
||||
disabled={vm.busy}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Add OpenCode profile
|
||||
{tr("Ajouter un profil OpenCode", "Add OpenCode profile")}
|
||||
</Button>
|
||||
</Toolbar>
|
||||
|
||||
@ -141,7 +142,7 @@ export function FirstRunWizard({
|
||||
entry.profile.structuredAdapter === "openCode"
|
||||
? () =>
|
||||
vm.addOpenCodeProfile({
|
||||
name: `${entry.profile.name} (copy)`,
|
||||
name: `${entry.profile.name} (${tr("copie", "copy")})`,
|
||||
opencode: entry.profile.opencode,
|
||||
})
|
||||
: undefined
|
||||
@ -152,7 +153,7 @@ export function FirstRunWizard({
|
||||
|
||||
<footer className="flex gap-2">
|
||||
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
|
||||
Save and continue
|
||||
{tr("Enregistrer et continuer", "Save and continue")}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
@ -181,6 +182,7 @@ function ProfileRow({
|
||||
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
|
||||
onDuplicate?: () => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const { profile, selected, available } = entry;
|
||||
const errors = validateProfile(profile);
|
||||
const isOpenCode = profile.structuredAdapter === "openCode";
|
||||
@ -203,13 +205,13 @@ function ProfileRow({
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
aria-label={`use ${profile.name}`}
|
||||
aria-label={tr(`Utiliser ${profile.name}`, `Use ${profile.name}`)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<strong className="text-sm text-content">{profile.name}</strong>
|
||||
</label>
|
||||
<span
|
||||
aria-label={`${profile.name} availability`}
|
||||
aria-label={tr(`Disponibilité de ${profile.name}`, `${profile.name} availability`)}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
available === null
|
||||
@ -219,21 +221,21 @@ function ProfileRow({
|
||||
: "text-danger",
|
||||
)}
|
||||
>
|
||||
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
|
||||
{available === null ? "—" : available ? tr("✓ installée", "✓ installed") : tr("✗ introuvable", "✗ not found")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onDuplicate && (
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`duplicate ${profile.name}`}
|
||||
aria-label={tr(`Dupliquer ${profile.name}`, `Duplicate ${profile.name}`)}
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
Duplicate
|
||||
{tr("Dupliquer", "Duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`remove ${profile.name}`}
|
||||
aria-label={tr(`Retirer ${profile.name}`, `Remove ${profile.name}`)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
@ -245,9 +247,9 @@ function ProfileRow({
|
||||
it is editable per profile (several local models coexist). */}
|
||||
{isOpenCode && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Name</Caption>
|
||||
<Caption>{tr("Nom", "Name")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} name`}
|
||||
aria-label={tr(`Nom de ${profile.name}`, `Name for ${profile.name}`)}
|
||||
value={profile.name}
|
||||
invalid={Boolean(errors.name)}
|
||||
onChange={(e) => onChange({ ...profile, name: e.target.value })}
|
||||
@ -257,9 +259,9 @@ function ProfileRow({
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Command</Caption>
|
||||
<Caption>{tr("Commande", "Command")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} command`}
|
||||
aria-label={tr(`Commande de ${profile.name}`, `Command for ${profile.name}`)}
|
||||
value={profile.command}
|
||||
invalid={Boolean(errors.command)}
|
||||
onChange={(e) => onChange({ ...profile, command: e.target.value })}
|
||||
@ -268,9 +270,9 @@ function ProfileRow({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Arguments</Caption>
|
||||
<Caption>{tr("Arguments", "Arguments")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} args`}
|
||||
aria-label={tr(`Arguments de ${profile.name}`, `Arguments for ${profile.name}`)}
|
||||
value={argsText}
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
onBlur={commitArgs}
|
||||
@ -307,13 +309,14 @@ function CliModelField({
|
||||
profile: AgentProfile;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={profile.model ?? ""}
|
||||
placeholder="Laisser vide pour le modèle par défaut de la CLI"
|
||||
placeholder={tr("Laisser vide pour le modèle par défaut de la CLI", "Leave empty for the CLI default model")}
|
||||
onChange={(e) => {
|
||||
const model = e.target.value.trim();
|
||||
onChange({
|
||||
@ -323,7 +326,7 @@ function CliModelField({
|
||||
}}
|
||||
/>
|
||||
<small className="text-xs text-faint">
|
||||
Optionnel. L'authentification Codex/Claude reste gérée par la CLI.
|
||||
{tr("Facultatif. L’authentification Codex/Claude reste gérée par la CLI.", "Optional. Codex/Claude authentication remains managed by the CLI.")}
|
||||
</small>
|
||||
</label>
|
||||
);
|
||||
@ -352,6 +355,7 @@ function HttpChatFields({
|
||||
errors: ProfileErrors;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const http = profile.chatHttp ?? defaultHttpChatConfig();
|
||||
const patch = (next: Partial<HttpChatConfig>) =>
|
||||
onChange({ ...profile, chatHttp: { ...http, ...next } });
|
||||
@ -359,13 +363,13 @@ function HttpChatFields({
|
||||
return (
|
||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Local / LAN model (OpenAI-compatible)
|
||||
{tr("Modèle local / réseau (compatible OpenAI)", "Local / LAN model (OpenAI-compatible)")}
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Endpoint</Caption>
|
||||
<Caption>{tr("Point d’accès", "Endpoint")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} endpoint`}
|
||||
aria-label={tr(`Point d’accès de ${profile.name}`, `Endpoint for ${profile.name}`)}
|
||||
value={http.endpoint}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
invalid={Boolean(errors.endpoint)}
|
||||
@ -377,9 +381,9 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Model</Caption>
|
||||
<Caption>{tr("Modèle", "Model")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
|
||||
value={http.model}
|
||||
placeholder="qwen2.5-coder"
|
||||
invalid={Boolean(errors.model)}
|
||||
@ -389,11 +393,11 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>API key — environment variable name (never the key itself)</Caption>
|
||||
<Caption>{tr("Clé API — nom de la variable d’environnement (jamais la clé elle-même)", "API key — environment variable name (never the key itself)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} api key env`}
|
||||
aria-label={tr(`Variable de clé API de ${profile.name}`, `API key environment variable for ${profile.name}`)}
|
||||
value={http.apiKeyEnv ?? ""}
|
||||
placeholder="e.g. OPENAI_API_KEY (variable name, not the secret)"
|
||||
placeholder={tr("ex. OPENAI_API_KEY (nom de variable, pas le secret)", "e.g. OPENAI_API_KEY (variable name, not the secret)")}
|
||||
invalid={Boolean(errors.apiKeyEnv)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
@ -407,9 +411,9 @@ function HttpChatFields({
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Request timeout (ms)</Caption>
|
||||
<Caption>{tr("Délai de requête (ms)", "Request timeout (ms)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} request timeout`}
|
||||
aria-label={tr(`Délai de requête de ${profile.name}`, `Request timeout for ${profile.name}`)}
|
||||
value={http.requestTimeoutMs?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.requestTimeoutMs)}
|
||||
@ -421,9 +425,9 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Connect timeout (ms)</Caption>
|
||||
<Caption>{tr("Délai de connexion (ms)", "Connect timeout (ms)")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} connect timeout`}
|
||||
aria-label={tr(`Délai de connexion de ${profile.name}`, `Connect timeout for ${profile.name}`)}
|
||||
value={http.connectTimeoutMs?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.connectTimeoutMs)}
|
||||
@ -435,9 +439,9 @@ function HttpChatFields({
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Max tool iterations</Caption>
|
||||
<Caption>{tr("Nombre maximal d’itérations d’outils", "Max tool iterations")}</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} max tool iterations`}
|
||||
aria-label={tr(`Itérations d’outils maximales de ${profile.name}`, `Max tool iterations for ${profile.name}`)}
|
||||
value={http.maxToolIterations?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.maxToolIterations)}
|
||||
|
||||
Reference in New Issue
Block a user