refactor(ui): unify shared primitives across pages

This commit is contained in:
thibaud-lclr 2026-04-20 08:55:26 +02:00
parent 8536dedc9e
commit 62b381b844
20 changed files with 483 additions and 258 deletions

View file

@ -0,0 +1,87 @@
# Audit UI/UX transverse - 2026-04-20
## Périmètre audité
Front React complet sous `src/components`:
- `agents/*`
- `projects/*`
- `tickets/*`
- `trackers/*`
- `settings/*`
- `layout/*`
## Incohérences détectées (avant refactor)
1. Boutons d'action dupliqués avec variations de couleurs et de tailles selon les pages (`bg-blue-600`, `bg-gray-900`, `bg-red-100`, etc.).
2. Champs de formulaire répétés avec plusieurs variantes de classes (`input`, `select`, `textarea`) non centralisées.
3. Cartes/blocs de contenu dupliqués (`rounded-lg border border-gray-200 bg-white p-4`) avec petites divergences de spacing.
4. Alertes (`error`, `warning`, `success`, `info`) construites page par page avec des styles proches mais non unifiés.
5. Badges de statut ticket réimplémentés dans plusieurs pages avec logique dupliquée et couverture de statuts non homogène.
## Composants réutilisés sur plusieurs pages
### 1) Boutons (multi-pages)
Usage sur:
- Agents: `AgentList`, `AgentForm`
- Projets: `ProjectForm`, `ProjectDashboard`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`
- Tickets: `TicketList`, `TicketDetail`
- Trackers: `TrackerConfig`, `TrackerList`, `FilterBuilder`
- Settings: `SettingsPage`
- Layout: `WindowControls`, `NotificationCenter`
- UI: `ConfirmModal`
Unification appliquée:
- Ajout de `buttonClass()` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Variantes centralisées: `primary`, `secondary`, `danger`, `dangerSoft`, `neutralDark`, `success`, `ghost`
- Tailles centralisées: `xs`, `sm`, `md`, `icon`
### 2) Formulaires (input/select/textarea + labels)
Usage sur:
- `AgentForm`, `ProjectForm`, `SettingsPage`, `TrackerConfig`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectTasks`, `TicketDetail`, `FilterBuilder`
Unification appliquée:
- `labelClass`, `inputClass`, `textAreaClass` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Remplacement des classes inline répétées par ces styles partagés.
### 3) Cartes de contenu
Usage sur:
- `ProjectDashboard`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`, `TrackerConfig`, `TrackerList`, `TicketList`, `TicketDetail`, `AgentList`
Unification appliquée:
- `cardClass` / `cardContentClass` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Réemploi des mêmes classes de surface sur les pages.
### 4) Alertes (erreur/succès/warning/info)
Usage sur:
- `AgentForm`, `AgentList`, `ProjectForm`, `ProjectGraylog`, `ProjectLiveAgent`, `ProjectModules`, `ProjectTasks`, `SettingsPage`, `TrackerConfig`, `TicketDetail`
Unification appliquée:
- `noticeClass(tone, compact)` dans [`src/components/ui/primitives.ts`](/home/leclere/Projets/IA/orchai/src/components/ui/primitives.ts)
- Tons standards: `error`, `success`, `warning`, `info`.
### 5) Badges de statut ticket
Usage sur:
- `TicketList`, `TicketDetail`, `ProjectDashboard`
Unification appliquée:
- Nouveau composant [`src/components/ui/TicketStatusBadge.tsx`](/home/leclere/Projets/IA/orchai/src/components/ui/TicketStatusBadge.tsx)
- Mapping de statuts mutualisé via `ticketStatusClass()`
- Suppression des mappings locaux dupliqués dans les pages tickets/dashboard.
## Méthode de génération commune
Oui, pour les composants réutilisés multi-pages identifiés, la génération du style passe désormais par une couche commune:
- `primitives.ts` pour les patterns UI transverses
- `TicketStatusBadge.tsx` pour les badges de statuts ticket
Cela réduit la divergence visuelle et simplifie les évolutions de design futures.
## Limites / reste à harmoniser
1. Certaines zones très contextuelles (ex: bulles de conversation live, badges de session archivée, blocs de diff) conservent des styles spécifiques, volontairement.
2. Une seconde passe peut encore factoriser quelques micro-patterns (ex: tabs, badges non-ticket, liens d'action inline) si nécessaire.

View file

@ -3,6 +3,14 @@ import { useNavigate, useParams } from "react-router-dom";
import { createAgent, getAgent, improveAgentPrompt, updateAgent } from "../../lib/api"; import { createAgent, getAgent, improveAgentPrompt, updateAgent } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { AgentRole, AgentTool } from "../../lib/types"; import type { AgentRole, AgentTool } from "../../lib/types";
import {
buttonClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
textAreaClass,
} from "../ui/primitives";
export default function AgentForm() { export default function AgentForm() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -76,36 +84,36 @@ export default function AgentForm() {
return ( return (
<div className="mx-auto max-w-2xl p-8"> <div className="mx-auto max-w-2xl p-8">
<h2 className="mb-6 text-xl font-bold">{isEditing ? "Edit agent" : "New agent"}</h2> <h2 className={`mb-6 ${pageTitleClass}`}>{isEditing ? "Edit agent" : "New agent"}</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>} {initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
{isEditing && isDefaultAgent && ( {isEditing && isDefaultAgent && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700"> <div className={noticeClass("info")}>
This is a default agent. Its tool and script/prompt can be modified, but its name and This is a default agent. Its tool and script/prompt can be modified, but its name and
role are fixed. role are fixed.
</div> </div>
)} )}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label> <label className={labelClass}>Name</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
disabled={isEditing && isDefaultAgent} disabled={isEditing && isDefaultAgent}
required required
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700">Role</label> <label className={labelClass}>Role</label>
<select <select
value={role} value={role}
onChange={(e) => setRole(e.target.value as AgentRole)} onChange={(e) => setRole(e.target.value as AgentRole)}
disabled={isEditing && isDefaultAgent} disabled={isEditing && isDefaultAgent}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
> >
<option value="analyst">Analyst</option> <option value="analyst">Analyst</option>
<option value="developer">Developer</option> <option value="developer">Developer</option>
@ -113,11 +121,11 @@ export default function AgentForm() {
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700">Tool</label> <label className={labelClass}>Tool</label>
<select <select
value={tool} value={tool}
onChange={(e) => setTool(e.target.value as AgentTool)} onChange={(e) => setTool(e.target.value as AgentTool)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
> >
<option value="codex">Codex</option> <option value="codex">Codex</option>
<option value="claude_code">Claude Code</option> <option value="claude_code">Claude Code</option>
@ -126,14 +134,14 @@ export default function AgentForm() {
<div> <div>
<div className="mb-1 flex items-center justify-between gap-3"> <div className="mb-1 flex items-center justify-between gap-3">
<label className="block text-sm font-medium text-gray-700"> <label className={labelClass}>
Script / custom prompt (appended to built-in prompt) Script / custom prompt (appended to built-in prompt)
</label> </label>
<button <button
type="button" type="button"
onClick={() => void handleImprovePrompt()} onClick={() => void handleImprovePrompt()}
disabled={loading || initializing || improvingPrompt} disabled={loading || initializing || improvingPrompt}
className="rounded border border-emerald-300 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50" className={buttonClass({ variant: "success", size: "xs" })}
> >
{improvingPrompt ? "Amélioration..." : "Amélioration"} {improvingPrompt ? "Amélioration..." : "Amélioration"}
</button> </button>
@ -143,7 +151,7 @@ export default function AgentForm() {
value={customPrompt} value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)} onChange={(e) => setCustomPrompt(e.target.value)}
disabled={loading || initializing || improvingPrompt} disabled={loading || initializing || improvingPrompt}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50" className={`${textAreaClass} disabled:bg-gray-50`}
placeholder="Extra instructions for this agent..." placeholder="Extra instructions for this agent..."
/> />
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
@ -152,7 +160,7 @@ export default function AgentForm() {
</div> </div>
{error && ( {error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600"> <div className={noticeClass("error", true)}>
{error} {error}
</div> </div>
)} )}
@ -161,14 +169,14 @@ export default function AgentForm() {
<button <button
type="submit" type="submit"
disabled={loading || initializing || improvingPrompt} disabled={loading || initializing || improvingPrompt}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{loading ? "Saving..." : isEditing ? "Save" : "Create"} {loading ? "Saving..." : isEditing ? "Save" : "Create"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300" className={buttonClass({ variant: "secondary" })}
> >
Cancel Cancel
</button> </button>

View file

@ -4,6 +4,13 @@ import { deleteAgent, listAgents } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { Agent } from "../../lib/types"; import type { Agent } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal"; import ConfirmModal from "../ui/ConfirmModal";
import {
buttonClass,
cardContentClass,
noticeClass,
pageClass,
pageTitleClass,
} from "../ui/primitives";
export default function AgentList() { export default function AgentList() {
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@ -52,13 +59,10 @@ export default function AgentList() {
} }
return ( return (
<div className="p-8"> <div className={pageClass}>
<div className="mb-6 flex items-center justify-between gap-3"> <div className="mb-6 flex items-center justify-between gap-3">
<h2 className="text-xl font-bold">Agents</h2> <h2 className={pageTitleClass}>Agents</h2>
<Link <Link to="/agents/new" className={buttonClass({ variant: "primary" })}>
to="/agents/new"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
New agent New agent
</Link> </Link>
</div> </div>
@ -66,7 +70,7 @@ export default function AgentList() {
{loading && <div className="text-sm text-gray-400">Loading...</div>} {loading && <div className="text-sm text-gray-400">Loading...</div>}
{error && ( {error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600"> <div className={`mb-4 ${noticeClass("error", true)}`}>
{error} {error}
</div> </div>
)} )}
@ -79,7 +83,7 @@ export default function AgentList() {
{agents.map((agent) => ( {agents.map((agent) => (
<div <div
key={agent.id} key={agent.id}
className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-blue-300 hover:shadow-sm focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100" className={`group relative transition hover:border-blue-300 hover:shadow-sm focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100 ${cardContentClass}`}
> >
<Link <Link
to={`/agents/${agent.id}/edit`} to={`/agents/${agent.id}/edit`}
@ -117,7 +121,7 @@ export default function AgentList() {
<button <button
type="button" type="button"
onClick={() => setAgentToDelete(agent)} onClick={() => setAgentToDelete(agent)}
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200" className={buttonClass({ variant: "dangerSoft", size: "xs" })}
> >
Delete Delete
</button> </button>

View file

@ -12,6 +12,7 @@ import {
markNotificationRead, markNotificationRead,
} from "../../lib/api"; } from "../../lib/api";
import type { OrchaiNotification } from "../../lib/types"; import type { OrchaiNotification } from "../../lib/types";
import { buttonClass, cardClass, pillClass } from "../ui/primitives";
type NewNotificationEvent = { type NewNotificationEvent = {
notification: OrchaiNotification; notification: OrchaiNotification;
@ -180,7 +181,7 @@ export default function NotificationCenter() {
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="relative rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" className={`${buttonClass({ variant: "secondary", size: "sm" })} relative border border-gray-300`}
> >
Notifications Notifications
{unreadCount > 0 && ( {unreadCount > 0 && (
@ -191,13 +192,13 @@ export default function NotificationCenter() {
</button> </button>
{open && ( {open && (
<div className="absolute right-0 z-20 mt-2 w-[360px] rounded-lg border border-gray-200 bg-white shadow-lg"> <div className={`absolute right-0 z-20 mt-2 w-[360px] shadow-lg ${cardClass}`}>
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2"> <div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<h3 className="text-sm font-semibold text-gray-800">Notifications</h3> <h3 className="text-sm font-semibold text-gray-800">Notifications</h3>
<button <button
type="button" type="button"
onClick={handleMarkAllRead} onClick={handleMarkAllRead}
className="text-xs text-blue-600 hover:underline" className={buttonClass({ variant: "ghost", size: "xs" })}
disabled={!projectId || unreadCount === 0} disabled={!projectId || unreadCount === 0}
> >
Mark all read Mark all read
@ -217,11 +218,7 @@ export default function NotificationCenter() {
onClick={() => onClick={() =>
setFilter(item.id as "all" | "unread" | "errors" | "fixes") setFilter(item.id as "all" | "unread" | "errors" | "fixes")
} }
className={`rounded px-2 py-1 text-xs ${ className={`${pillClass(filter === item.id)} text-xs`}
filter === item.id
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
> >
{item.label} {item.label}
</button> </button>

View file

@ -1,4 +1,5 @@
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { buttonClass } from "../ui/primitives";
async function minimizeWindow() { async function minimizeWindow() {
try { try {
@ -31,7 +32,7 @@ export default function WindowControls() {
type="button" type="button"
onClick={() => void minimizeWindow()} onClick={() => void minimizeWindow()}
title="Minimize" title="Minimize"
className="h-7 w-7 rounded border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-100" className={`${buttonClass({ variant: "secondary", size: "icon" })} border border-gray-300`}
> >
_ _
</button> </button>
@ -39,7 +40,7 @@ export default function WindowControls() {
type="button" type="button"
onClick={() => void toggleMaximizeWindow()} onClick={() => void toggleMaximizeWindow()}
title="Maximize / Restore" title="Maximize / Restore"
className="h-7 w-7 rounded border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-100" className={`${buttonClass({ variant: "secondary", size: "icon" })} border border-gray-300`}
> >
[] []
</button> </button>
@ -47,7 +48,7 @@ export default function WindowControls() {
type="button" type="button"
onClick={() => void closeWindow()} onClick={() => void closeWindow()}
title="Close" title="Close"
className="h-7 w-7 rounded border border-red-300 bg-red-50 text-xs text-red-700 hover:bg-red-100" className={`${buttonClass({ variant: "dangerSoft", size: "icon" })} border border-red-300`}
> >
X X
</button> </button>

View file

@ -16,6 +16,8 @@ import type {
} from "../../lib/types"; } from "../../lib/types";
import TrackerList from "../trackers/TrackerList"; import TrackerList from "../trackers/TrackerList";
import ConfirmModal from "../ui/ConfirmModal"; import ConfirmModal from "../ui/ConfirmModal";
import TicketStatusBadge from "../ui/TicketStatusBadge";
import { buttonClass, cardContentClass, pageClass, pageTitleClass } from "../ui/primitives";
type ActivityLevel = "info" | "success" | "error"; type ActivityLevel = "info" | "success" | "error";
@ -331,19 +333,6 @@ export default function ProjectDashboard() {
navigate("/"); navigate("/");
} }
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
default:
return "bg-blue-100 text-blue-700";
}
}
function formatLeadTime(seconds: number | null): string { function formatLeadTime(seconds: number | null): string {
if (seconds === null || Number.isNaN(seconds)) { if (seconds === null || Number.isNaN(seconds)) {
return "—"; return "—";
@ -374,26 +363,26 @@ export default function ProjectDashboard() {
const errorRate24h = resolved24h > 0 ? `${Math.round((error24h / resolved24h) * 100)}%` : "—"; const errorRate24h = resolved24h > 0 ? `${Math.round((error24h / resolved24h) * 100)}%` : "—";
return ( return (
<div className="p-8"> <div className={pageClass}>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">{project.name}</h2> <h2 className={pageTitleClass}>{project.name}</h2>
<div className="flex gap-2"> <div className="flex gap-2">
<Link <Link
to={`/projects/${project.id}/edit`} to={`/projects/${project.id}/edit`}
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300" className={buttonClass({ variant: "secondary", size: "sm" })}
> >
Edit Edit
</Link> </Link>
<button <button
onClick={() => setIsDeleteModalOpen(true)} onClick={() => setIsDeleteModalOpen(true)}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200" className={buttonClass({ variant: "dangerSoft", size: "sm" })}
> >
Delete Delete
</button> </button>
</div> </div>
</div> </div>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3"> <div className={`${cardContentClass} space-y-3`}>
<div> <div>
<span className="text-sm text-gray-500">Path:</span> <span className="text-sm text-gray-500">Path:</span>
<span className="ml-2 text-sm font-mono">{project.path}</span> <span className="ml-2 text-sm font-mono">{project.path}</span>
@ -499,7 +488,7 @@ export default function ProjectDashboard() {
<div className="mt-8"> <div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Live Activity</h3> <h3 className="text-lg font-semibold mb-4">Live Activity</h3>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4"> <div className={`${cardContentClass} space-y-4`}>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<div className="text-sm rounded-full bg-blue-50 text-blue-700 px-3 py-1"> <div className="text-sm rounded-full bg-blue-50 text-blue-700 px-3 py-1">
Polling en cours: {activePollList.length} Polling en cours: {activePollList.length}
@ -566,7 +555,7 @@ export default function ProjectDashboard() {
<Link <Link
key={ticket.id} key={ticket.id}
to={`/tickets/${ticket.id}`} to={`/tickets/${ticket.id}`}
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4" className={`${cardContentClass} flex items-center justify-between gap-4`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -574,11 +563,7 @@ export default function ProjectDashboard() {
<span className="text-sm font-medium truncate">{ticket.artifact_title}</span> <span className="text-sm font-medium truncate">{ticket.artifact_title}</span>
</div> </div>
</div> </div>
<span <TicketStatusBadge status={ticket.status} className="shrink-0" />
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${statusBadgeClass(ticket.status)}`}
>
{ticket.status}
</span>
</Link> </Link>
))} ))}
</div> </div>

View file

@ -3,6 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { createProject, getProject, updateProject } from "../../lib/api"; import { createProject, getProject, updateProject } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import {
buttonClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
} from "../ui/primitives";
export default function ProjectForm() { export default function ProjectForm() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -60,13 +67,13 @@ export default function ProjectForm() {
return ( return (
<div className="max-w-lg mx-auto p-8"> <div className="max-w-lg mx-auto p-8">
<h2 className="text-xl font-bold mb-6"> <h2 className={`${pageTitleClass} mb-6`}>
{isEditing ? "Edit project" : "New project"} {isEditing ? "Edit project" : "New project"}
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Project name Project name
</label> </label>
<input <input
@ -74,14 +81,14 @@ export default function ProjectForm() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
{!isEditing && ( {!isEditing && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Source Source
</label> </label>
<div className="flex gap-4"> <div className="flex gap-4">
@ -105,7 +112,7 @@ export default function ProjectForm() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
{mode === "local" ? "Folder path" : "Git URL"} {mode === "local" ? "Folder path" : "Git URL"}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
@ -119,13 +126,13 @@ export default function ProjectForm() {
? "/home/user/code/myproject" ? "/home/user/code/myproject"
: "https://github.com/org/repo.git" : "https://github.com/org/repo.git"
} }
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={`flex-1 ${inputClass}`}
/> />
{mode === "local" && ( {mode === "local" && (
<button <button
type="button" type="button"
onClick={handleBrowse} onClick={handleBrowse}
className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300" className={buttonClass({ variant: "secondary" })}
> >
Browse Browse
</button> </button>
@ -136,7 +143,7 @@ export default function ProjectForm() {
)} )}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Base branch Base branch
</label> </label>
<input <input
@ -144,12 +151,12 @@ export default function ProjectForm() {
value={baseBranch} value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)} onChange={(e) => setBaseBranch(e.target.value)}
required required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
{error && ( {error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2"> <div className={noticeClass("error", true)}>
{error} {error}
</div> </div>
)} )}
@ -158,14 +165,14 @@ export default function ProjectForm() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{loading ? "Saving..." : isEditing ? "Save" : "Create"} {loading ? "Saving..." : isEditing ? "Save" : "Create"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300" className={buttonClass({ variant: "secondary" })}
> >
Cancel Cancel
</button> </button>

View file

@ -16,6 +16,15 @@ import type {
GraylogDetection, GraylogDetection,
GraylogSubject, GraylogSubject,
} from "../../lib/types"; } from "../../lib/types";
import {
backLinkClass,
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageStackClass,
pageTitleClass,
} from "../ui/primitives";
export default function ProjectGraylog() { export default function ProjectGraylog() {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
@ -146,35 +155,32 @@ export default function ProjectGraylog() {
} }
return ( return (
<div className="space-y-6 p-8"> <div className={pageStackClass}>
<div> <div>
{projectId && ( {projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline"> <Link to={`/projects/${projectId}`} className={backLinkClass}>
Back Back
</Link> </Link>
)} )}
<h2 className="text-xl font-bold">Graylog</h2> <h2 className={pageTitleClass}>Graylog</h2>
</div> </div>
{error && ( {error && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700"> <div className={noticeClass("error")}>
{error} {error}
</div> </div>
)} )}
{success && ( {success && (
<div className="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700"> <div className={noticeClass("success")}>
{success} {success}
</div> </div>
)} )}
<form <form onSubmit={handleSave} className={`space-y-3 ${cardContentClass}`}>
onSubmit={handleSave}
className="space-y-3 rounded-lg border border-gray-200 bg-white p-4"
>
<h3 className="text-sm font-semibold text-gray-900">Configuration</h3> <h3 className="text-sm font-semibold text-gray-900">Configuration</h3>
<input <input
className="w-full rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={baseUrl} value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)} onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://graylog.example.com" placeholder="https://graylog.example.com"
@ -182,7 +188,7 @@ export default function ProjectGraylog() {
/> />
<input <input
className="w-full rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={apiToken} value={apiToken}
onChange={(event) => setApiToken(event.target.value)} onChange={(event) => setApiToken(event.target.value)}
placeholder={ placeholder={
@ -195,7 +201,7 @@ export default function ProjectGraylog() {
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<select <select
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={analystAgentId} value={analystAgentId}
onChange={(event) => setAnalystAgentId(event.target.value)} onChange={(event) => setAnalystAgentId(event.target.value)}
required required
@ -209,7 +215,7 @@ export default function ProjectGraylog() {
</select> </select>
<select <select
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={developerAgentId} value={developerAgentId}
onChange={(event) => setDeveloperAgentId(event.target.value)} onChange={(event) => setDeveloperAgentId(event.target.value)}
required required
@ -225,13 +231,13 @@ export default function ProjectGraylog() {
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<input <input
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={streamId} value={streamId}
onChange={(event) => setStreamId(event.target.value)} onChange={(event) => setStreamId(event.target.value)}
placeholder="stream_id (optionnel)" placeholder="stream_id (optionnel)"
/> />
<input <input
className="rounded border border-gray-300 px-3 py-2 text-sm md:col-span-3" className={`${inputClass} md:col-span-3`}
value={queryFilter} value={queryFilter}
onChange={(event) => setQueryFilter(event.target.value)} onChange={(event) => setQueryFilter(event.target.value)}
placeholder="Filtre query Graylog" placeholder="Filtre query Graylog"
@ -241,21 +247,21 @@ export default function ProjectGraylog() {
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<input <input
type="number" type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={pollingIntervalMinutes} value={pollingIntervalMinutes}
onChange={(event) => setPollingIntervalMinutes(Number(event.target.value))} onChange={(event) => setPollingIntervalMinutes(Number(event.target.value))}
min={1} min={1}
/> />
<input <input
type="number" type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={lookbackMinutes} value={lookbackMinutes}
onChange={(event) => setLookbackMinutes(Number(event.target.value))} onChange={(event) => setLookbackMinutes(Number(event.target.value))}
min={1} min={1}
/> />
<input <input
type="number" type="number"
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
value={scoreThreshold} value={scoreThreshold}
onChange={(event) => setScoreThreshold(Number(event.target.value))} onChange={(event) => setScoreThreshold(Number(event.target.value))}
min={1} min={1}
@ -267,28 +273,28 @@ export default function ProjectGraylog() {
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{saving ? "Sauvegarde..." : "Sauvegarder"} {saving ? "Sauvegarde..." : "Sauvegarder"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => void handleTestConnection()} onClick={() => void handleTestConnection()}
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300" className={buttonClass({ variant: "secondary" })}
> >
Tester la connexion Tester la connexion
</button> </button>
<button <button
type="button" type="button"
onClick={() => void handleManualPoll()} onClick={() => void handleManualPoll()}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black" className={buttonClass({ variant: "neutralDark" })}
> >
Poll manuel Poll manuel
</button> </button>
</div> </div>
</form> </form>
<div className="rounded-lg border border-gray-200 bg-white p-4"> <div className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-900">Sujets détectés</h3> <h3 className="mb-3 text-sm font-semibold text-gray-900">Sujets détectés</h3>
{loading ? ( {loading ? (
@ -311,7 +317,7 @@ export default function ProjectGraylog() {
)} )}
</div> </div>
<div className="rounded-lg border border-gray-200 bg-white p-4"> <div className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-900">Dernières détections</h3> <h3 className="mb-3 text-sm font-semibold text-gray-900">Dernières détections</h3>
{detections.length === 0 ? ( {detections.length === 0 ? (

View file

@ -12,6 +12,16 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { Agent, LiveMessage, LiveSession } from "../../lib/types"; import type { Agent, LiveMessage, LiveSession } from "../../lib/types";
import {
backLinkClass,
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageStackClass,
pageTitleClass,
pillClass,
} from "../ui/primitives";
interface LiveEventPayload { interface LiveEventPayload {
project_id: string; project_id: string;
@ -303,34 +313,34 @@ export default function ProjectLiveAgent() {
} }
return ( return (
<div className="p-8 space-y-6"> <div className={pageStackClass}>
<div> <div>
{projectId && ( {projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline"> <Link to={`/projects/${projectId}`} className={backLinkClass}>
Back Back
</Link> </Link>
)} )}
<h2 className="text-xl font-bold">Live agent</h2> <h2 className={pageTitleClass}>Live agent</h2>
</div> </div>
{error && ( {error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600"> <div className={noticeClass("error", true)}>
{error} {error}
</div> </div>
)} )}
{!moduleEnabled && ( {!moduleEnabled && (
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700"> <div className={noticeClass("warning", true)}>
Le module est désactivé. La lecture reste possible, mais la création de session et l'envoi de message sont bloqués. Le module est désactivé. La lecture reste possible, mais la création de session et l'envoi de message sont bloqués.
</div> </div>
)} )}
<form onSubmit={handleCreateSession} className="rounded-lg border border-gray-200 bg-white p-4"> <form onSubmit={handleCreateSession} className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Nouvelle session</h3> <h3 className="mb-3 text-sm font-semibold text-gray-800">Nouvelle session</h3>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<select <select
value={selectedAgentId} value={selectedAgentId}
onChange={(e) => setSelectedAgentId(e.target.value)} onChange={(e) => setSelectedAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
> >
{usableAgents.map((agent) => ( {usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}> <option key={agent.id} value={agent.id}>
@ -343,12 +353,12 @@ export default function ProjectLiveAgent() {
value={sessionTitle} value={sessionTitle}
onChange={(e) => setSessionTitle(e.target.value)} onChange={(e) => setSessionTitle(e.target.value)}
placeholder="Titre de session (optionnel)" placeholder="Titre de session (optionnel)"
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
/> />
<button <button
type="submit" type="submit"
disabled={creatingSession || !selectedAgentId || !moduleEnabled} disabled={creatingSession || !selectedAgentId || !moduleEnabled}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{creatingSession ? "Création..." : "Créer la session"} {creatingSession ? "Création..." : "Créer la session"}
</button> </button>
@ -356,7 +366,7 @@ export default function ProjectLiveAgent() {
</form> </form>
<div className="grid gap-4 md:grid-cols-[260px,1fr]"> <div className="grid gap-4 md:grid-cols-[260px,1fr]">
<div className="rounded-lg border border-gray-200 bg-white p-3"> <div className={`${cardContentClass} p-3`}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500"> <div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Sessions Sessions
</div> </div>
@ -371,11 +381,7 @@ export default function ProjectLiveAgent() {
type="button" type="button"
key={session.id} key={session.id}
onClick={() => void handleSessionChange(session.id)} onClick={() => void handleSessionChange(session.id)}
className={`w-full rounded px-3 py-2 text-left text-sm ${ className={`${pillClass(selectedSessionId === session.id)} w-full text-left`}
selectedSessionId === session.id
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
> >
<div className="truncate">{session.title}</div> <div className="truncate">{session.title}</div>
<div className="mt-1 text-[11px] opacity-70"> <div className="mt-1 text-[11px] opacity-70">
@ -426,7 +432,7 @@ export default function ProjectLiveAgent() {
</div> </div>
</div> </div>
<div className="rounded-lg border border-gray-200 bg-white p-4"> <div className={cardContentClass}>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-gray-800">Discussion</div> <div className="text-sm font-semibold text-gray-800">Discussion</div>
@ -505,7 +511,7 @@ export default function ProjectLiveAgent() {
: "Ton message..." : "Ton message..."
} }
disabled={!selectedSessionId || sending || selectedSession?.status === "archived"} disabled={!selectedSessionId || sending || selectedSession?.status === "archived"}
className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm" className={`flex-1 ${inputClass}`}
/> />
<button <button
type="submit" type="submit"
@ -516,7 +522,7 @@ export default function ProjectLiveAgent() {
!moduleEnabled || !moduleEnabled ||
selectedSession?.status === "archived" selectedSession?.status === "archived"
} }
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black disabled:opacity-50" className={buttonClass({ variant: "neutralDark" })}
> >
{sending ? "Envoi..." : "Envoyer"} {sending ? "Envoi..." : "Envoyer"}
</button> </button>

View file

@ -3,6 +3,14 @@ import { Link, useParams } from "react-router-dom";
import { listProjectModules, setProjectModuleEnabled } from "../../lib/api"; import { listProjectModules, setProjectModuleEnabled } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { ProjectModule } from "../../lib/types"; import type { ProjectModule } from "../../lib/types";
import {
buttonClass,
cardContentClass,
noticeClass,
pageClass,
pageTitleClass,
backLinkClass,
} from "../ui/primitives";
export default function ProjectModules() { export default function ProjectModules() {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
@ -42,18 +50,18 @@ export default function ProjectModules() {
} }
return ( return (
<div className="p-8"> <div className={pageClass}>
<div className="mb-6"> <div className="mb-6">
{projectId && ( {projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline"> <Link to={`/projects/${projectId}`} className={backLinkClass}>
Back Back
</Link> </Link>
)} )}
<h2 className="text-xl font-bold">Modules du projet</h2> <h2 className={pageTitleClass}>Modules du projet</h2>
</div> </div>
{error && ( {error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600"> <div className={`mb-4 ${noticeClass("error", true)}`}>
{error} {error}
</div> </div>
)} )}
@ -62,7 +70,7 @@ export default function ProjectModules() {
{modules.map((mod) => ( {modules.map((mod) => (
<div <div
key={mod.id} key={mod.id}
className="rounded-lg border border-gray-200 bg-white p-4" className={cardContentClass}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
@ -72,7 +80,7 @@ export default function ProjectModules() {
{projectId && mod.module_key === "graylog_polling_auto_resolve" && ( {projectId && mod.module_key === "graylog_polling_auto_resolve" && (
<Link <Link
to={`/projects/${projectId}/graylog`} to={`/projects/${projectId}/graylog`}
className="mt-2 inline-flex rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200" className={`mt-2 ${buttonClass({ variant: "secondary", size: "xs" })}`}
> >
Configurer Configurer
</Link> </Link>

View file

@ -11,6 +11,16 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { Agent, AgentTask } from "../../lib/types"; import type { Agent, AgentTask } from "../../lib/types";
import {
backLinkClass,
buttonClass,
cardContentClass,
inputClass,
noticeClass,
pageStackClass,
pageTitleClass,
textAreaClass,
} from "../ui/primitives";
interface TaskEventPayload { interface TaskEventPayload {
project_id: string; project_id: string;
@ -152,34 +162,34 @@ export default function ProjectTasks() {
} }
return ( return (
<div className="space-y-6 p-8"> <div className={pageStackClass}>
<div> <div>
{projectId && ( {projectId && (
<Link to={`/projects/${projectId}`} className="mb-1 inline-flex text-sm text-blue-600 hover:underline"> <Link to={`/projects/${projectId}`} className={backLinkClass}>
Back Back
</Link> </Link>
)} )}
<h2 className="text-xl font-bold">Tâches agent</h2> <h2 className={pageTitleClass}>Tâches agent</h2>
</div> </div>
{error && ( {error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600"> <div className={noticeClass("error", true)}>
{error} {error}
</div> </div>
)} )}
{!moduleEnabled && ( {!moduleEnabled && (
<div className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700"> <div className={noticeClass("warning", true)}>
Le module est désactivé. Les tâches existantes restent visibles, mais la création et la relance sont bloquées. Le module est désactivé. Les tâches existantes restent visibles, mais la création et la relance sont bloquées.
</div> </div>
)} )}
<form onSubmit={handleCreateTask} className="rounded-lg border border-gray-200 bg-white p-4"> <form onSubmit={handleCreateTask} className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Créer une tâche</h3> <h3 className="mb-3 text-sm font-semibold text-gray-800">Créer une tâche</h3>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<select <select
value={agentId} value={agentId}
onChange={(e) => setAgentId(e.target.value)} onChange={(e) => setAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
> >
{usableAgents.map((agent) => ( {usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}> <option key={agent.id} value={agent.id}>
@ -193,13 +203,13 @@ export default function ProjectTasks() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche" placeholder="Titre de la tâche"
className="rounded border border-gray-300 px-3 py-2 text-sm" className={inputClass}
/> />
<button <button
type="submit" type="submit"
disabled={creating || !agentId || !title.trim() || !moduleEnabled} disabled={creating || !agentId || !title.trim() || !moduleEnabled}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{creating ? "Création..." : "Créer"} {creating ? "Création..." : "Créer"}
</button> </button>
@ -210,13 +220,13 @@ export default function ProjectTasks() {
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={4} rows={4}
placeholder="Description détaillée" placeholder="Description détaillée"
className="mt-3 w-full rounded border border-gray-300 px-3 py-2 text-sm" className={`mt-3 ${textAreaClass}`}
/> />
</form> </form>
<div className="space-y-3"> <div className="space-y-3">
{tasks.map((task) => ( {tasks.map((task) => (
<div key={task.id} className="rounded-lg border border-gray-200 bg-white p-4"> <div key={task.id} className={cardContentClass}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900">{task.title}</div> <div className="truncate text-sm font-semibold text-gray-900">{task.title}</div>
@ -234,13 +244,13 @@ export default function ProjectTasks() {
)} )}
{task.result && ( {task.result && (
<div className="mt-3 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900 whitespace-pre-wrap"> <div className={`mt-3 whitespace-pre-wrap ${noticeClass("success")}`}>
{task.result} {task.result}
</div> </div>
)} )}
{task.error && ( {task.error && (
<div className="mt-3 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 whitespace-pre-wrap"> <div className={`mt-3 whitespace-pre-wrap ${noticeClass("error")}`}>
{task.error} {task.error}
</div> </div>
)} )}
@ -251,7 +261,7 @@ export default function ProjectTasks() {
type="button" type="button"
onClick={() => void handleRetry(task.id)} onClick={() => void handleRetry(task.id)}
disabled={workingTaskId === task.id || !moduleEnabled} disabled={workingTaskId === task.id || !moduleEnabled}
className="rounded bg-gray-900 px-3 py-1 text-xs text-white hover:bg-black disabled:opacity-50" className={buttonClass({ variant: "neutralDark", size: "xs" })}
> >
Relancer Relancer
</button> </button>
@ -262,7 +272,7 @@ export default function ProjectTasks() {
type="button" type="button"
onClick={() => void handleCancel(task.id)} onClick={() => void handleCancel(task.id)}
disabled={workingTaskId === task.id} disabled={workingTaskId === task.id}
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50" className={buttonClass({ variant: "dangerSoft", size: "xs" })}
> >
Annuler Annuler
</button> </button>

View file

@ -10,6 +10,14 @@ import {
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { Project, TuleapCredentialsSafe } from "../../lib/types"; import type { Project, TuleapCredentialsSafe } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal"; import ConfirmModal from "../ui/ConfirmModal";
import {
buttonClass,
cardClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
} from "../ui/primitives";
function normalizeScope(value: string): string | null { function normalizeScope(value: string): string | null {
return value === "" ? null : value; return value === "" ? null : value;
@ -160,17 +168,17 @@ export default function SettingsPage() {
return ( return (
<div className="max-w-lg mx-auto p-8"> <div className="max-w-lg mx-auto p-8">
<h2 className="text-xl font-bold mb-6">Settings</h2> <h2 className={`${pageTitleClass} mb-6`}>Settings</h2>
<div className="bg-white rounded-lg border border-gray-200 p-6"> <div className={`${cardClass} p-6`}>
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3> <h3 className="text-base font-semibold mb-4">Tuleap credentials</h3>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Scope</label> <label className={labelClass}>Scope</label>
<select <select
value={selectedProjectId ?? ""} value={selectedProjectId ?? ""}
onChange={(e) => setSelectedProjectId(normalizeScope(e.target.value))} onChange={(e) => setSelectedProjectId(normalizeScope(e.target.value))}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
> >
<option value="">Global fallback</option> <option value="">Global fallback</option>
{projects.map((project) => ( {projects.map((project) => (
@ -191,54 +199,54 @@ export default function SettingsPage() {
) : null} ) : null}
{usingGlobalFallback && ( {usingGlobalFallback && (
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700"> <div className={`mb-4 ${noticeClass("warning")}`}>
Aucun credential spécifique au projet n'est configuré. Le fallback global est utilisé. Aucun credential spécifique au projet n'est configuré. Le fallback global est utilisé.
</div> </div>
)} )}
<form onSubmit={handleSave} className="space-y-4"> <form onSubmit={handleSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tuleap URL</label> <label className={labelClass}>Tuleap URL</label>
<input <input
type="url" type="url"
value={tuleapUrl} value={tuleapUrl}
onChange={(e) => setTuleapUrl(e.target.value)} onChange={(e) => setTuleapUrl(e.target.value)}
required required
placeholder="https://tuleap.example.com" placeholder="https://tuleap.example.com"
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label> <label className={labelClass}>Username</label>
<input <input
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label> <label className={labelClass}>Password</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
{error && ( {error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2"> <div className={noticeClass("error", true)}>
{error} {error}
</div> </div>
)} )}
{success && ( {success && (
<div className="text-green-700 text-sm bg-green-50 border border-green-200 rounded p-2"> <div className={noticeClass("success", true)}>
{success} {success}
</div> </div>
)} )}
@ -247,7 +255,7 @@ export default function SettingsPage() {
<button <button
type="submit" type="submit"
disabled={saving || loadingScope} disabled={saving || loadingScope}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{saving ? "Saving..." : "Save"} {saving ? "Saving..." : "Save"}
</button> </button>
@ -255,7 +263,7 @@ export default function SettingsPage() {
type="button" type="button"
onClick={handleTest} onClick={handleTest}
disabled={testing || !existing || loadingScope} disabled={testing || !existing || loadingScope}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50" className={buttonClass({ variant: "secondary" })}
> >
{testing ? "Testing..." : "Test connection"} {testing ? "Testing..." : "Test connection"}
</button> </button>
@ -264,7 +272,7 @@ export default function SettingsPage() {
type="button" type="button"
onClick={() => setIsDeleteModalOpen(true)} onClick={() => setIsDeleteModalOpen(true)}
disabled={deleting} disabled={deleting}
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700 disabled:opacity-50" className={buttonClass({ variant: "danger" })}
> >
{deleting ? "Deleting..." : "Delete"} {deleting ? "Deleting..." : "Delete"}
</button> </button>

View file

@ -14,25 +14,15 @@ import {
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { ProcessedTicket, Worktree } from "../../lib/types"; import type { ProcessedTicket, Worktree } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal"; import ConfirmModal from "../ui/ConfirmModal";
import TicketStatusBadge from "../ui/TicketStatusBadge";
function statusBadgeClass(status: string): string { import {
switch (status) { buttonClass,
case "Pending": cardContentClass,
return "bg-yellow-100 text-yellow-700"; inputClass,
case "Analyzing": noticeClass,
return "bg-blue-100 text-blue-700"; pageClass,
case "Developing": pageTitleClass,
return "bg-purple-100 text-purple-700"; } from "../ui/primitives";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
function DiffViewer({ diff }: { diff: string }) { function DiffViewer({ diff }: { diff: string }) {
if (!diff) { if (!diff) {
@ -228,22 +218,19 @@ export default function TicketDetail() {
]; ];
return ( return (
<div className="p-8"> <div className={pageClass}>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<button onClick={() => navigate(-1)} className="mb-1 text-sm text-blue-600 hover:underline"> <button
onClick={() => navigate(-1)}
className={buttonClass({ variant: "ghost", size: "xs" })}
>
Back Back
</button> </button>
<h2 className="flex items-center gap-3 text-xl font-bold"> <h2 className={`flex items-center gap-3 ${pageTitleClass}`}>
<span className="font-mono text-base text-gray-400">#{ticket.artifact_id}</span> <span className="font-mono text-base text-gray-400">#{ticket.artifact_id}</span>
{ticket.artifact_title} {ticket.artifact_title}
<span <TicketStatusBadge status={ticket.status} />
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
</h2> </h2>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -251,7 +238,7 @@ export default function TicketDetail() {
<button <button
onClick={handleRetry} onClick={handleRetry}
disabled={loading} disabled={loading}
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary", size: "sm" })}
> >
Retry Retry
</button> </button>
@ -262,7 +249,7 @@ export default function TicketDetail() {
<button <button
onClick={handleCancel} onClick={handleCancel}
disabled={loading} disabled={loading}
className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 disabled:opacity-50" className={buttonClass({ variant: "dangerSoft", size: "sm" })}
> >
Cancel Cancel
</button> </button>
@ -271,7 +258,7 @@ export default function TicketDetail() {
</div> </div>
{error && ( {error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div> <div className={`mb-4 ${noticeClass("error")}`}>{error}</div>
)} )}
<div className="mb-6 flex gap-1 border-b border-gray-200"> <div className="mb-6 flex gap-1 border-b border-gray-200">
@ -295,7 +282,7 @@ export default function TicketDetail() {
{tab === "info" && ( {tab === "info" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-4"> <div className={`space-y-3 ${cardContentClass}`}>
<div> <div>
<span className="text-sm text-gray-500">Status:</span> <span className="text-sm text-gray-500">Status:</span>
<span className="ml-2 text-sm">{ticket.status}</span> <span className="ml-2 text-sm">{ticket.status}</span>
@ -340,7 +327,7 @@ export default function TicketDetail() {
</div> </div>
{worktree && worktree.status === "Active" && ( {worktree && worktree.status === "Active" && (
<div className="rounded-lg border border-gray-200 bg-white p-4"> <div className={cardContentClass}>
<h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3> <h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs text-gray-500">Target branch</p> <p className="text-xs text-gray-500">Target branch</p>
@ -350,7 +337,7 @@ export default function TicketDetail() {
onClick={() => onClick={() =>
setBranchInputMode((mode) => (mode === "select" ? "manual" : "select")) setBranchInputMode((mode) => (mode === "select" ? "manual" : "select"))
} }
className="text-xs text-blue-600 hover:underline" className={buttonClass({ variant: "ghost", size: "xs" })}
> >
{branchInputMode === "select" {branchInputMode === "select"
? "Use manual input" ? "Use manual input"
@ -364,7 +351,7 @@ export default function TicketDetail() {
<select <select
value={targetBranch} value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)} onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500" className={`flex-1 bg-white ${inputClass}`}
> >
{availableBranches.map((branch) => ( {availableBranches.map((branch) => (
<option key={branch} value={branch}> <option key={branch} value={branch}>
@ -378,13 +365,13 @@ export default function TicketDetail() {
placeholder="Target branch (e.g. feature/login)" placeholder="Target branch (e.g. feature/login)"
value={targetBranch} value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)} onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500" className={`flex-1 ${inputClass}`}
/> />
)} )}
<button <button
onClick={handleApplyFix} onClick={handleApplyFix}
disabled={loading || branchesLoading || !targetBranch.trim()} disabled={loading || branchesLoading || !targetBranch.trim()}
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50" className={buttonClass({ variant: "success", size: "sm" })}
> >
Apply fix Apply fix
</button> </button>
@ -406,7 +393,7 @@ export default function TicketDetail() {
<button <button
onClick={() => setIsDeleteWorktreeModalOpen(true)} onClick={() => setIsDeleteWorktreeModalOpen(true)}
disabled={loading} disabled={loading}
className="text-sm text-red-600 hover:underline" className={buttonClass({ variant: "dangerGhost", size: "xs" })}
> >
Delete worktree Delete worktree
</button> </button>
@ -414,7 +401,7 @@ export default function TicketDetail() {
)} )}
{worktree && worktree.status === "Merged" && ( {worktree && worktree.status === "Merged" && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700"> <div className={noticeClass("info")}>
Fix applied to branch: {worktree.merged_into} Fix applied to branch: {worktree.merged_into}
</div> </div>
)} )}

View file

@ -2,25 +2,13 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { getProject, listProcessedTickets } from "../../lib/api"; import { getProject, listProcessedTickets } from "../../lib/api";
import type { ProcessedTicket, Project } from "../../lib/types"; import type { ProcessedTicket, Project } from "../../lib/types";
import TicketStatusBadge from "../ui/TicketStatusBadge";
function statusBadgeClass(status: string): string { import {
switch (status) { cardContentClass,
case "Pending": pageClass,
return "bg-yellow-100 text-yellow-700"; pageTitleClass,
case "Analyzing": pillClass,
return "bg-blue-100 text-blue-700"; } from "../ui/primitives";
case "Developing":
return "bg-purple-100 text-purple-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
export default function TicketList() { export default function TicketList() {
const { projectId } = useParams(); const { projectId } = useParams();
@ -41,13 +29,13 @@ export default function TicketList() {
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter); const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
return ( return (
<div className="p-8"> <div className={pageClass}>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<Link to={`/projects/${projectId}`} className="text-sm text-blue-600 hover:underline"> <Link to={`/projects/${projectId}`} className="text-sm text-blue-600 hover:underline">
{project?.name} {project?.name}
</Link> </Link>
<h2 className="text-xl font-bold">Processed Tickets</h2> <h2 className={pageTitleClass}>Processed Tickets</h2>
</div> </div>
</div> </div>
@ -56,11 +44,7 @@ export default function TicketList() {
<button <button
key={s} key={s}
onClick={() => setFilter(s)} onClick={() => setFilter(s)}
className={`rounded px-3 py-1 text-sm ${ className={pillClass(filter === s)}
filter === s
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
> >
{s === "all" ? "All" : s} {s === "all" ? "All" : s}
{s !== "all" && ( {s !== "all" && (
@ -80,7 +64,7 @@ export default function TicketList() {
<Link <Link
key={ticket.id} key={ticket.id}
to={`/tickets/${ticket.id}`} to={`/tickets/${ticket.id}`}
className="block rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300" className={`block transition-colors hover:border-blue-300 ${cardContentClass}`}
> >
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -100,13 +84,7 @@ export default function TicketList() {
)} )}
</div> </div>
</div> </div>
<span <TicketStatusBadge status={ticket.status} className="shrink-0" />
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
</div> </div>
</Link> </Link>
))} ))}

View file

@ -1,4 +1,5 @@
import type { FilterGroup, Filter, TrackerField } from "../../lib/types"; import type { FilterGroup, Filter, TrackerField } from "../../lib/types";
import { buttonClass, cardContentClass, inputClass } from "../ui/primitives";
const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const; const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const;
@ -60,13 +61,13 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<div className="flex-1 border-t border-gray-200" /> <div className="flex-1 border-t border-gray-200" />
</div> </div>
)} )}
<div className="bg-white rounded-lg border border-gray-200 p-4"> <div className={cardContentClass}>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">Group {gi + 1}</span> <span className="text-sm font-medium text-gray-700">Group {gi + 1}</span>
<button <button
type="button" type="button"
onClick={() => removeGroup(gi)} onClick={() => removeGroup(gi)}
className="text-xs text-red-500 hover:text-red-700" className={buttonClass({ variant: "dangerGhost", size: "xs" })}
> >
Remove group Remove group
</button> </button>
@ -96,7 +97,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
value: [], value: [],
}) })
} }
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1" className={`flex-1 ${inputClass}`}
> >
<option value="">Select field...</option> <option value="">Select field...</option>
{availableFields.map((f) => ( {availableFields.map((f) => (
@ -112,7 +113,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
onChange={(e) => onChange={(e) =>
updateCondition(gi, ci, { ...cond, operator: e.target.value }) updateCondition(gi, ci, { ...cond, operator: e.target.value })
} }
className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
> >
{OPERATORS.map((op) => ( {OPERATORS.map((op) => (
<option key={op} value={op}> <option key={op} value={op}>
@ -125,7 +126,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<button <button
type="button" type="button"
onClick={() => removeCondition(gi, ci)} onClick={() => removeCondition(gi, ci)}
className="text-xs text-red-400 hover:text-red-600 whitespace-nowrap" className={`${buttonClass({ variant: "dangerGhost", size: "xs" })} whitespace-nowrap`}
> >
Remove Remove
</button> </button>
@ -162,7 +163,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<button <button
type="button" type="button"
onClick={() => addCondition(gi)} onClick={() => addCondition(gi)}
className="mt-3 text-xs text-blue-600 hover:text-blue-800" className={`${buttonClass({ variant: "ghost", size: "xs" })} mt-3`}
> >
+ Add OR condition + Add OR condition
</button> </button>
@ -173,7 +174,7 @@ export default function FilterBuilder({ groups, onChange, availableFields }: Pro
<button <button
type="button" type="button"
onClick={addGroup} onClick={addGroup}
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors" className={`${buttonClass({ variant: "secondary", fullWidth: true })} border-2 border-dashed border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600`}
> >
+ Add filter group (AND) + Add filter group (AND)
</button> </button>

View file

@ -11,6 +11,14 @@ import {
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { FilterGroup, TrackerField, Agent } from "../../lib/types"; import type { FilterGroup, TrackerField, Agent } from "../../lib/types";
import FilterBuilder from "./FilterBuilder"; import FilterBuilder from "./FilterBuilder";
import {
buttonClass,
cardContentClass,
inputClass,
labelClass,
noticeClass,
pageTitleClass,
} from "../ui/primitives";
export default function TrackerConfig() { export default function TrackerConfig() {
const { projectId, trackerConfigId } = useParams<{ projectId: string; trackerConfigId: string }>(); const { projectId, trackerConfigId } = useParams<{ projectId: string; trackerConfigId: string }>();
@ -165,7 +173,7 @@ export default function TrackerConfig() {
return ( return (
<div className="max-w-2xl mx-auto p-8"> <div className="max-w-2xl mx-auto p-8">
<h2 className="text-xl font-bold mb-6"> <h2 className={`${pageTitleClass} mb-6`}>
{isEditing ? "Edit tracker" : "Add tracker"} {isEditing ? "Edit tracker" : "Add tracker"}
</h2> </h2>
@ -175,21 +183,21 @@ export default function TrackerConfig() {
)} )}
{analystAgents.length === 0 || developerAgents.length === 0 ? ( {analystAgents.length === 0 || developerAgents.length === 0 ? (
<div className="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700"> <div className={noticeClass("warning")}>
You need at least one analyst agent and one developer agent before creating a tracker. You need at least one analyst agent and one developer agent before creating a tracker.
</div> </div>
) : null} ) : null}
{isEditing && trackerStatus === "invalid" && ( {isEditing && trackerStatus === "invalid" && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700"> <div className={noticeClass("error")}>
This tracker is invalid. Select valid agents and save to reactivate it. This tracker is invalid. Select valid agents and save to reactivate it.
</div> </div>
)} )}
{/* Basic fields */} {/* Basic fields */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4"> <div className={`${cardContentClass} space-y-4`}>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Tracker ID Tracker ID
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
@ -203,14 +211,14 @@ export default function TrackerConfig() {
}} }}
required required
min={1} min={1}
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={`w-40 ${inputClass}`}
placeholder="e.g. 42" placeholder="e.g. 42"
/> />
<button <button
type="button" type="button"
onClick={handleLoadFields} onClick={handleLoadFields}
disabled={!trackerId || fieldsLoading} disabled={!trackerId || fieldsLoading}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{fieldsLoading ? "Loading..." : "Load tracker fields"} {fieldsLoading ? "Loading..." : "Load tracker fields"}
</button> </button>
@ -218,7 +226,7 @@ export default function TrackerConfig() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Label Label
</label> </label>
<input <input
@ -227,12 +235,12 @@ export default function TrackerConfig() {
onChange={(e) => setTrackerLabel(e.target.value)} onChange={(e) => setTrackerLabel(e.target.value)}
required required
placeholder="e.g. Bugs" placeholder="e.g. Bugs"
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Polling interval (minutes) Polling interval (minutes)
</label> </label>
<input <input
@ -241,7 +249,7 @@ export default function TrackerConfig() {
onChange={(e) => setPollingInterval(Number(e.target.value))} onChange={(e) => setPollingInterval(Number(e.target.value))}
required required
min={1} min={1}
className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={`w-40 ${inputClass}`}
/> />
</div> </div>
@ -270,17 +278,17 @@ export default function TrackerConfig() {
)} )}
{/* Agent config */} {/* Agent config */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4"> <div className={`${cardContentClass} space-y-4`}>
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3> <h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Analyst agent Analyst agent
</label> </label>
<select <select
value={analystAgentId} value={analystAgentId}
onChange={(e) => setAnalystAgentId(e.target.value)} onChange={(e) => setAnalystAgentId(e.target.value)}
required required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
> >
<option value="">Select an analyst agent</option> <option value="">Select an analyst agent</option>
{analystAgents.map((agent) => ( {analystAgents.map((agent) => (
@ -291,14 +299,14 @@ export default function TrackerConfig() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className={labelClass}>
Developer agent Developer agent
</label> </label>
<select <select
value={developerAgentId} value={developerAgentId}
onChange={(e) => setDeveloperAgentId(e.target.value)} onChange={(e) => setDeveloperAgentId(e.target.value)}
required required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputClass}
> >
<option value="">Select a developer agent</option> <option value="">Select a developer agent</option>
{developerAgents.map((agent) => ( {developerAgents.map((agent) => (
@ -311,7 +319,7 @@ export default function TrackerConfig() {
</div> </div>
{error && ( {error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2"> <div className={noticeClass("error", true)}>
{error} {error}
</div> </div>
)} )}
@ -320,14 +328,14 @@ export default function TrackerConfig() {
<button <button
type="submit" type="submit"
disabled={loading || initializing} disabled={loading || initializing}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary" })}
> >
{loading ? "Saving..." : isEditing ? "Save tracker" : "Add tracker"} {loading ? "Saving..." : isEditing ? "Save tracker" : "Add tracker"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate(`/projects/${projectId}`)} onClick={() => navigate(`/projects/${projectId}`)}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300" className={buttonClass({ variant: "secondary" })}
> >
Cancel Cancel
</button> </button>

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { manualPoll, updateTracker, removeTracker } from "../../lib/api"; import { manualPoll, updateTracker, removeTracker } from "../../lib/api";
import type { WatchedTracker } from "../../lib/types"; import type { WatchedTracker } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal"; import ConfirmModal from "../ui/ConfirmModal";
import { buttonClass, cardContentClass } from "../ui/primitives";
interface Props { interface Props {
trackers: WatchedTracker[]; trackers: WatchedTracker[];
@ -68,7 +69,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
{trackers.map((tracker) => ( {trackers.map((tracker) => (
<div <div
key={tracker.id} key={tracker.id}
className="bg-white rounded-lg border border-gray-200 p-4 flex items-center justify-between gap-4" className={`flex items-center justify-between gap-4 ${cardContentClass}`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -98,13 +99,13 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
type="button" type="button"
onClick={() => handlePollNow(tracker)} onClick={() => handlePollNow(tracker)}
disabled={pollingIds.includes(tracker.id) || tracker.status !== "valid"} disabled={pollingIds.includes(tracker.id) || tracker.status !== "valid"}
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:opacity-50" className={buttonClass({ variant: "primary", size: "xs" })}
> >
{pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"} {pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"}
</button> </button>
<Link <Link
to={`/projects/${projectId}/trackers/${tracker.id}/edit`} to={`/projects/${projectId}/trackers/${tracker.id}/edit`}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300" className={buttonClass({ variant: "secondary", size: "xs" })}
> >
Edit Edit
</Link> </Link>
@ -112,14 +113,14 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
type="button" type="button"
onClick={() => handleToggleEnabled(tracker)} onClick={() => handleToggleEnabled(tracker)}
disabled={tracker.status !== "valid"} disabled={tracker.status !== "valid"}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300" className={buttonClass({ variant: "secondary", size: "xs" })}
> >
{tracker.enabled ? "Pause" : "Resume"} {tracker.enabled ? "Pause" : "Resume"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setTrackerToRemove(tracker)} onClick={() => setTrackerToRemove(tracker)}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200" className={buttonClass({ variant: "dangerSoft", size: "xs" })}
> >
Remove Remove
</button> </button>
@ -129,7 +130,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<Link <Link
to={`/projects/${projectId}/trackers/new`} to={`/projects/${projectId}/trackers/new`}
className="inline-block px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700" className={buttonClass({ variant: "primary" })}
> >
Add tracker Add tracker
</Link> </Link>

View file

@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { buttonClass, cardClass } from "./primitives";
interface ConfirmModalProps { interface ConfirmModalProps {
isOpen: boolean; isOpen: boolean;
@ -45,7 +46,7 @@ export default function ConfirmModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl" className={`w-full max-w-md p-5 shadow-xl ${cardClass}`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<h3 className="text-base font-semibold text-gray-900">{title}</h3> <h3 className="text-base font-semibold text-gray-900">{title}</h3>
@ -55,7 +56,7 @@ export default function ConfirmModal({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="rounded bg-gray-200 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-300" className={buttonClass({ variant: "secondary", size: "sm" })}
> >
{cancelLabel} {cancelLabel}
</button> </button>
@ -63,7 +64,7 @@ export default function ConfirmModal({
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
disabled={confirmDisabled} disabled={confirmDisabled}
className="rounded bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50" className={buttonClass({ variant: "danger", size: "sm" })}
> >
{confirmLabel} {confirmLabel}
</button> </button>

View file

@ -0,0 +1,33 @@
import { cx } from "./primitives";
const statusClasses: Record<string, string> = {
Pending: "bg-yellow-100 text-yellow-700",
Analyzing: "bg-blue-100 text-blue-700",
Developing: "bg-purple-100 text-purple-700",
Done: "bg-green-100 text-green-700",
Error: "bg-red-100 text-red-700",
Cancelled: "bg-gray-100 text-gray-500",
};
export function ticketStatusClass(status: string): string {
return statusClasses[status] ?? "bg-gray-100 text-gray-700";
}
interface TicketStatusBadgeProps {
status: string;
className?: string;
}
export default function TicketStatusBadge({ status, className }: TicketStatusBadgeProps) {
return (
<span
className={cx(
"rounded-full px-2 py-0.5 text-xs font-medium",
ticketStatusClass(status),
className
)}
>
{status}
</span>
);
}

View file

@ -0,0 +1,89 @@
export function cx(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
export type ButtonVariant =
| "primary"
| "secondary"
| "danger"
| "dangerSoft"
| "dangerGhost"
| "neutralDark"
| "success"
| "ghost";
export type ButtonSize = "xs" | "sm" | "md" | "icon";
interface ButtonClassOptions {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
}
const buttonVariantClasses: Record<ButtonVariant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-700 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700",
dangerSoft: "bg-red-100 text-red-700 hover:bg-red-200",
dangerGhost: "bg-transparent text-red-600 hover:underline",
neutralDark: "bg-gray-900 text-white hover:bg-black",
success: "bg-green-600 text-white hover:bg-green-700",
ghost: "bg-transparent text-blue-600 hover:underline",
};
const buttonSizeClasses: Record<ButtonSize, string> = {
xs: "px-3 py-1 text-xs",
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
icon: "h-7 w-7 px-0 text-xs",
};
export function buttonClass({
variant = "secondary",
size = "md",
fullWidth = false,
}: ButtonClassOptions = {}): string {
return cx(
"inline-flex items-center justify-center rounded font-medium transition-colors disabled:opacity-50",
buttonVariantClasses[variant],
buttonSizeClasses[size],
fullWidth && "w-full"
);
}
export const pageClass = "p-8";
export const pageStackClass = "space-y-6 p-8";
export const pageTitleClass = "text-xl font-bold";
export const backLinkClass = "mb-1 inline-flex text-sm text-blue-600 hover:underline";
export const cardClass = "rounded-lg border border-gray-200 bg-white";
export const cardContentClass = `${cardClass} p-4`;
export const labelClass = "mb-1 block text-sm font-medium text-gray-700";
export const inputClass =
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
export const textAreaClass =
"w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
export type NoticeTone = "error" | "success" | "warning" | "info";
const noticeToneClasses: Record<NoticeTone, string> = {
error: "border-red-200 bg-red-50 text-red-700",
success: "border-green-200 bg-green-50 text-green-700",
warning: "border-amber-200 bg-amber-50 text-amber-700",
info: "border-blue-200 bg-blue-50 text-blue-700",
};
export function noticeClass(tone: NoticeTone, compact = false): string {
return cx(
"rounded border text-sm",
compact ? "p-2" : "p-3",
noticeToneClasses[tone]
);
}
export function pillClass(active: boolean): string {
return active
? "rounded px-3 py-1 text-sm bg-gray-900 text-white"
: "rounded px-3 py-1 text-sm bg-gray-100 text-gray-700 hover:bg-gray-200";
}