refactor(frontend): share Tailwind dialog and form controls
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]" />;
|
||||
}
|
||||
Reference in New Issue
Block a user