refactor(frontend): share Tailwind dialog and form controls

This commit is contained in:
2026-10-07 09:55:57 -03:00
parent e9808cb12b
commit 264d7905c6
4 changed files with 235 additions and 109 deletions
+21 -8
View File
@@ -1,13 +1,22 @@
import { X } from "lucide-react";
import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
import { IconButton } from "./ui";
interface Props {
title: string;
children: ReactNode;
onClose: () => void;
className?: string;
size?: "default" | "about" | "profile" | "drawer";
}
export function Dialog({ title, children, onClose, className = "" }: Props) {
const sizes = {
default: "w-[min(440px,92vw)] rounded-panel",
about: "max-h-[88vh] w-[min(680px,92vw)] rounded-panel",
profile: "max-h-[90vh] w-[min(480px,90vw)] rounded-panel",
drawer: "m-0 h-dvh max-h-dvh w-[min(360px,92vw)] rounded-none",
};
export function Dialog({ title, children, onClose, size = "default" }: Props) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useLayoutEffect(() => {
@@ -24,7 +33,7 @@ export function Dialog({ title, children, onClose, className = "" }: Props) {
return (
<dialog
ref={ref}
className={`modal ${className}`}
className={`border border-solid border-line bg-surface-raised p-0 text-foreground shadow-panel backdrop:bg-[rgb(8_9_13/0.65)] backdrop:backdrop-blur-[4px] ${sizes[size]}`}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
@@ -36,12 +45,16 @@ export function Dialog({ title, children, onClose, className = "" }: Props) {
}
}}
>
<div className="modal-content">
<header className="modal-header">
<h2 id={titleId}>{title}</h2>
<button type="button" className="icon-btn" aria-label="Cerrar" onClick={onClose}>
<div
className={`flex flex-col white:border white:border-solid white:border-line white:bg-surface white:text-foreground ${size === "about" ? "max-h-[88vh] gap-5 overflow-y-auto px-[30px] pt-[26px] pb-6" : "gap-4 px-6 pt-[22px] pb-5"}`}
>
<header className="mb-1 flex items-center justify-between gap-3">
<h2 className="m-0 text-[17px] font-semibold tracking-[0.02em] compact:text-[16px]" id={titleId}>
{title}
</h2>
<IconButton aria-label="Cerrar" onClick={onClose}>
<X size={18} />
</button>
</IconButton>
</header>
{children}
</div>
@@ -48,7 +48,7 @@ export function ReportsPanel({
);
}
return open ? (
<Dialog title="Reportes" className="reports-drawer" onClose={onClose}>
<Dialog title="Reportes" size="drawer" onClose={onClose}>
<div id="reports-sidebar">
<ReportContent reports={reports} />
</div>
+132 -100
View File
@@ -5,6 +5,7 @@ import { profileRows } from "../lib/profile";
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
import { Dialog } from "./Dialog";
import { Button, DialogFooter, DialogIntro, Field, FieldHint, Input, Select, Textarea } from "./ui";
interface Props {
state: AppState;
@@ -27,12 +28,12 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
onClose();
}}
>
<div className="tab-panels">
<div className="tab-panel active">
<div className="flex flex-col">
<div className="flex flex-col gap-4">
{config.allowDirect && !config.lb && (
<label className="field">
<Field>
<span>URL del servidor de voz</span>
<input
<Input
type="text"
autoComplete="off"
spellCheck={false}
@@ -43,17 +44,17 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, directUrl: event.target.value });
}}
/>
<small>
<FieldHint>
{config.s2sUrl
? "El servidor fija esta dirección para todas las conversaciones."
: "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."}
</small>
</label>
</FieldHint>
</Field>
)}
<div className="field-row">
<label className="field">
<div className="grid grid-cols-2 gap-3">
<Field>
<span>Voz</span>
<select
<Select
value={draft.voice}
onChange={(event) => {
setDraft({ ...draft, voice: event.target.value });
@@ -62,13 +63,13 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
{VOICES.map((voice) => (
<option key={voice}>{voice}</option>
))}
</select>
</label>
</Select>
</Field>
</div>
<div className="field">
<label className="field-head" htmlFor="noise-gate">
<div className="flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px]">
<label className="flex items-baseline justify-between" htmlFor="noise-gate">
Puerta de ruido
<span className="field-value">
<span className="text-[12px] font-medium text-muted">
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
</span>
</label>
@@ -93,14 +94,14 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
<span>−3 dB</span>
</div>
</div>
<small>
<FieldHint>
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
para desactivarla.
</small>
</FieldHint>
</div>
<label className="field">
<Field>
<span>Instrucciones</span>
<textarea
<Textarea
rows={5}
value={config.primaryPrompt || draft.instructions}
readOnly={!!config.primaryPrompt}
@@ -108,12 +109,13 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, instructions: event.target.value });
}}
/>
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
</label>
<div className="field">
<button
{config.primaryPrompt && <FieldHint>Las instrucciones están definidas por el servidor.</FieldHint>}
</Field>
<div className="flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px]">
<Button
variant="primary"
type="button"
className="btn primary wide"
className="w-full"
disabled={!LIVE_STATES.has(state.status)}
onClick={() => {
controller.changeSettings(draft);
@@ -122,22 +124,22 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
}}
>
Reiniciar conversación con esta configuración
</button>
<small>
</Button>
<FieldHint>
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
una nueva conversación.
</small>
</FieldHint>
</div>
</div>
</div>
<footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
<DialogFooter>
<Button type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit">
</Button>
<Button variant="primary" type="submit">
Guardar
</button>
</footer>
</Button>
</DialogFooter>
</form>
</Dialog>
);
@@ -157,10 +159,10 @@ export function RagDialog({ state, controller, onClose }: Props) {
const [saved, setSaved] = useState(false);
return (
<Dialog title="Contexto RAG personalizado" onClose={onClose}>
<p className="tools-intro">
<DialogIntro>
Añade documentos, datos o instrucciones que Vocero debe tener en cuenta durante esta conversación. El contexto
se mantiene mientras esta página esté abierta.
</p>
</DialogIntro>
<form
onSubmit={(event) => {
event.preventDefault();
@@ -168,9 +170,9 @@ export function RagDialog({ state, controller, onClose }: Props) {
setSaved(true);
}}
>
<label className="field">
<Field>
<span>Contexto RAG (texto libre)</span>
<textarea
<Textarea
rows={12}
spellCheck={false}
value={text}
@@ -180,17 +182,16 @@ export function RagDialog({ state, controller, onClose }: Props) {
}}
placeholder="Pega o escribe el contexto que Vocero debe usar al responder…"
/>
</label>
<p className="tool-hint" role="status">
</Field>
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
{saved
? "Contexto actualizado para esta conversación."
: state.rag
? `${String(state.rag.length)} caracteres de contexto cargados.`
: "No hay contexto RAG cargado."}
</p>
<footer className="modal-footer">
<button
className="btn secondary"
<DialogFooter>
<Button
type="button"
onClick={() => {
setText("");
@@ -199,12 +200,12 @@ export function RagDialog({ state, controller, onClose }: Props) {
}}
>
Vaciar
</button>
</Button>
<div className="flex-1" />
<button className="btn primary" type="submit">
<Button variant="primary" type="submit">
Enviar a la sesión
</button>
</footer>
</Button>
</DialogFooter>
</form>
</Dialog>
);
@@ -217,9 +218,9 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
}));
return (
<Dialog title="Herramientas" onClose={onClose}>
<p className="tools-intro">
<DialogIntro>
Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar.
</p>
</DialogIntro>
<form
onSubmit={(event) => {
event.preventDefault();
@@ -227,14 +228,17 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
onClose();
}}
>
<div className="tool-list">
<div className="tool-row">
<div className="tool-info">
<span className="tool-name">Búsqueda web</span>
<span className="tool-desc">Busca información en Google mediante Serper.</span>
<div className="flex flex-col">
<div className="flex items-center justify-between gap-4 py-3">
<div className="flex min-w-0 flex-col gap-[3px]">
<span className="text-[15px] font-semibold text-foreground compact:text-[14px]">Búsqueda web</span>
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
Busca información en Google mediante Serper.
</span>
</div>
<label className="switch">
<label className="relative inline-flex h-6 w-10 shrink-0 cursor-pointer">
<input
className="peer absolute inset-0 z-10 m-0 size-full cursor-pointer opacity-0"
type="checkbox"
aria-label="Búsqueda web"
checked={draft.webSearch}
@@ -242,12 +246,15 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, webSearch: event.target.checked });
}}
/>
<span className="switch-track" aria-hidden="true" />
<span
className="pointer-events-none absolute inset-0 rounded-full border border-solid border-line-strong bg-surface transition-colors peer-checked:border-foreground peer-checked:bg-foreground peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent after:absolute after:top-1/2 after:left-[3px] after:size-4 after:-translate-y-1/2 after:rounded-full after:bg-muted after:transition-transform after:content-[''] peer-checked:after:translate-x-4 peer-checked:after:bg-surface white:bg-slate-300 white:peer-checked:bg-accent"
aria-hidden="true"
/>
</label>
</div>
<label className="field tools-key">
<Field className="mb-1">
<span>Clave de la API de búsqueda</span>
<input
<Input
type="password"
autoComplete="off"
value={draft.searchKey}
@@ -256,19 +263,23 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, searchKey: event.target.value });
}}
/>
<small>
<FieldHint>
{state.config.search
? "La búsqueda está configurada en el servidor."
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
</small>
</label>
<div className="tool-row tool-row-sep">
<div className="tool-info">
<span className="tool-name">Herramientas MCP cargadas</span>
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</FieldHint>
</Field>
<div className="mt-1 flex items-center justify-between gap-4 border-0 border-t border-solid border-line py-3">
<div className="flex min-w-0 flex-col gap-[3px]">
<span className="text-[15px] font-semibold text-foreground compact:text-[14px]">
Herramientas MCP cargadas
</span>
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
Herramientas publicadas por el perfil remoto del agente.
</span>
</div>
</div>
<p className="tool-hint" role="status">
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
{state.toolsLoading
? "Cargando herramientas…"
: state.toolsError ||
@@ -276,23 +287,23 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")}
</p>
<div className="agent-tools-list">
<div className="flex flex-col gap-2">
{state.tools.map((tool) => (
<article key={tool.name} className="agent-tool-item">
<article key={tool.name} className="border-0 border-b border-solid border-line py-3 wrap-anywhere">
<strong className="block text-sm">{tool.name}</strong>
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
</article>
))}
</div>
</div>
<footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
<DialogFooter>
<Button type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit">
</Button>
<Button variant="primary" type="submit">
Guardar
</button>
</footer>
</Button>
</DialogFooter>
</form>
</Dialog>
);
@@ -302,18 +313,23 @@ export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
const name = profileName(state.profile) || "Usuario";
const rows = profileRows(state.profile);
return (
<Dialog title="Datos del usuario" className="user-profile-modal" onClose={onClose}>
<div className="user-profile-hero">
<div className="user-profile-avatar" aria-hidden="true">
<Dialog title="Datos del usuario" size="profile" onClose={onClose}>
<div className="flex flex-col items-center gap-3.5 pt-1.5 text-center">
<div
className="relative inline-flex size-[88px] shrink-0 items-center justify-center rounded-full border border-solid border-brand/40 bg-linear-to-br from-accent to-brand-tertiary p-0 text-[34px] leading-none font-extrabold tracking-[0.04em] text-white uppercase shadow-brand select-none max-[480px]:size-[72px] max-[480px]:text-[28px]"
aria-hidden="true"
>
{initials(name)}
</div>
<h3 className="user-profile-title">{name}</h3>
<h3 className="m-0 max-w-full text-[22px] leading-[1.2] font-extrabold tracking-[-0.01em] wrap-anywhere text-foreground max-[480px]:text-[19px]">
{name}
</h3>
</div>
<dl className="user-profile-metadata">
<dl className="m-0 grid gap-y-3 border-0 border-t border-solid border-line px-1 pt-[18px] pb-1 empty:hidden max-[480px]:gap-y-1.5 max-[480px]:pt-4">
{rows.map(({ label, value, href }) => (
<div key={label} className="user-profile-row">
<dt className="user-profile-label">{label}</dt>
<dd className="user-profile-value">{href ? <a href={href}>{value}</a> : value}</dd>
<div key={label} className="grid grid-cols-[90px_minmax(0,1fr)] gap-4 py-3">
<dt className="text-muted">{label}</dt>
<dd className="m-0 min-w-0 wrap-anywhere">{href ? <a href={href}>{value}</a> : value}</dd>
</div>
))}
</dl>
@@ -323,19 +339,25 @@ export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
export function AboutDialog({ onClose }: { onClose: () => void }) {
return (
<Dialog title="Acerca de" className="about-modal" onClose={onClose}>
<p className="tools-intro">
<Dialog title="Acerca de" size="about" onClose={onClose}>
<DialogIntro>
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
conversación.
</p>
<div className="about-pipeline">
<ol className="pipeline">
<li className="pipe-endpoint">Tú hablas</li>
<li className="pipe-stage">
<span className="pipe-tag">STT</span>
<span className="pipe-job">transcribe tu voz</span>
</DialogIntro>
<div className="border-0 border-t border-solid border-line pt-4">
<ol className="relative m-0 list-none p-0 before:absolute before:inset-y-[7px] before:left-2 before:w-px before:bg-line-strong before:content-['']">
<li className="relative pb-3 pl-[30px] font-mono text-[12px] font-medium tracking-[0.12em] text-faint uppercase before:absolute before:top-[3px] before:left-[5px] before:size-[7px] before:rounded-full before:border before:border-solid before:border-faint before:bg-surface-raised before:content-[''] last:pb-0 compact:text-[11px]">
Tú hablas
</li>
<li className="relative pb-4 pl-[30px] before:absolute before:top-1 before:left-[5px] before:size-[7px] before:rounded-full before:bg-muted before:content-['']">
<span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
STT
</span>
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
transcribe tu voz
</span>
<a
className="pipe-model"
className="mt-1 inline-flex items-center gap-1 border-0 font-mono text-[13px] break-all text-foreground hover:underline hover:underline-offset-2 compact:text-[12px]"
href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3"
target="_blank"
rel="noopener noreferrer"
@@ -343,11 +365,15 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
nvidia/parakeet-tdt-0.6b-v3
</a>
</li>
<li className="pipe-stage">
<span className="pipe-tag">LLM</span>
<span className="pipe-job">genera una respuesta</span>
<li className="relative pb-4 pl-[30px] before:absolute before:top-1 before:left-[5px] before:size-[7px] before:rounded-full before:bg-muted before:content-['']">
<span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
LLM
</span>
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
genera una respuesta
</span>
<a
className="pipe-model"
className="mt-1 inline-flex items-center gap-1 border-0 font-mono text-[13px] break-all text-foreground hover:underline hover:underline-offset-2 compact:text-[12px]"
href="https://huggingface.co/google/gemma-4-31B-it"
target="_blank"
rel="noopener noreferrer"
@@ -355,11 +381,15 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
google/gemma-4-31B-it
</a>
</li>
<li className="pipe-stage">
<span className="pipe-tag">TTS</span>
<span className="pipe-job">la convierte en voz</span>
<li className="relative pb-4 pl-[30px] before:absolute before:top-1 before:left-[5px] before:size-[7px] before:rounded-full before:bg-muted before:content-['']">
<span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
TTS
</span>
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
la convierte en voz
</span>
<a
className="pipe-model"
className="mt-1 inline-flex items-center gap-1 border-0 font-mono text-[13px] break-all text-foreground hover:underline hover:underline-offset-2 compact:text-[12px]"
href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
target="_blank"
rel="noopener noreferrer"
@@ -367,7 +397,9 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
</a>
</li>
<li className="pipe-endpoint">Vocero responde</li>
<li className="relative pb-3 pl-[30px] font-mono text-[12px] font-medium tracking-[0.12em] text-faint uppercase before:absolute before:top-[3px] before:left-[5px] before:size-[7px] before:rounded-full before:border before:border-solid before:border-faint before:bg-surface-raised before:content-[''] last:pb-0 compact:text-[11px]">
Vocero responde
</li>
</ol>
</div>
</Dialog>
+81
View File
@@ -0,0 +1,81 @@
import type { ComponentProps } from "react";
export function Button({
variant = "secondary",
className = "",
type = "button",
...props
}: ComponentProps<"button"> & { variant?: "primary" | "secondary" }) {
return (
<button
{...props}
type={type}
className={`rounded-control border border-solid px-4 py-2.5 text-[14px] font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent enabled:cursor-pointer enabled:active:translate-y-px disabled:cursor-not-allowed disabled:opacity-45 compact:text-[13px] ${
variant === "primary"
? "border-foreground bg-foreground text-surface enabled:hover:bg-white white:border-transparent white:bg-linear-to-br white:from-accent white:to-brand-secondary white:text-white white:disabled:opacity-60"
: "border-line-strong bg-surface-overlay text-foreground enabled:hover:border-foreground white:border-slate-900/10 white:enabled:hover:bg-slate-200"
} ${className}`}
/>
);
}
export function IconButton({
className = "",
type = "button",
round = false,
...props
}: ComponentProps<"button"> & { round?: boolean }) {
return (
<button
{...props}
type={type}
className={`relative inline-flex size-10 shrink-0 touch-manipulation items-center justify-center border border-solid border-line bg-surface-raised p-0 text-muted transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent enabled:cursor-pointer enabled:hover:border-line-strong enabled:hover:bg-surface-overlay enabled:hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45 ${round ? "rounded-full" : "rounded-control"} ${className}`}
/>
);
}
export function Field({ className = "", ...props }: ComponentProps<"label">) {
return (
<label
{...props}
className={`flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px] [&>span]:text-[13px] [&>span]:font-semibold [&>span]:tracking-[0.04em] [&>span]:text-foreground [&>span]:uppercase compact:[&>span]:text-[12px] ${className}`}
/>
);
}
const inputStyles =
"min-w-0 rounded-control border border-solid border-line bg-surface px-3 py-2.5 font-sans text-[14px] text-foreground outline-none transition-colors focus:border-muted disabled:opacity-60 white:border-slate-900/10 white:bg-surface-raised white:focus:border-brand-secondary white:focus:shadow-[0_0_0_3px_rgb(59_130_246/0.15)]";
export function Input({ className = "", ...props }: ComponentProps<"input">) {
return (
<input
{...props}
className={`${inputStyles} read-only:cursor-default read-only:border-dashed read-only:text-muted ${className}`}
/>
);
}
export function Select({ className = "", ...props }: ComponentProps<"select">) {
return <select {...props} className={`${inputStyles} ${className}`} />;
}
export function Textarea({ className = "", ...props }: ComponentProps<"textarea">) {
return (
<textarea
{...props}
className={`${inputStyles} min-h-[84px] resize-y text-[15px] compact:text-[14px] ${className}`}
/>
);
}
export function FieldHint({ className = "", ...props }: ComponentProps<"small">) {
return <small {...props} className={`text-[13px] leading-[1.4] text-faint compact:text-[12px] ${className}`} />;
}
export function DialogFooter({ className = "", ...props }: ComponentProps<"footer">) {
return <footer {...props} className={`flex justify-between gap-3 pt-1.5 ${className}`} />;
}
export function DialogIntro(props: ComponentProps<"p">) {
return <p {...props} className="m-0 text-[14px] leading-[1.5] text-muted compact:text-[13px]" />;
}