refactor(frontend): share Tailwind dialog and form controls
This commit is contained in:
@@ -1,13 +1,22 @@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
|
import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { IconButton } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onClose: () => void;
|
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 ref = useRef<HTMLDialogElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -24,7 +33,7 @@ export function Dialog({ title, children, onClose, className = "" }: Props) {
|
|||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={ref}
|
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}
|
aria-labelledby={titleId}
|
||||||
onCancel={(event) => {
|
onCancel={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -36,12 +45,16 @@ export function Dialog({ title, children, onClose, className = "" }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal-content">
|
<div
|
||||||
<header className="modal-header">
|
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"}`}
|
||||||
<h2 id={titleId}>{title}</h2>
|
>
|
||||||
<button type="button" className="icon-btn" aria-label="Cerrar" onClick={onClose}>
|
<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} />
|
<X size={18} />
|
||||||
</button>
|
</IconButton>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function ReportsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return open ? (
|
return open ? (
|
||||||
<Dialog title="Reportes" className="reports-drawer" onClose={onClose}>
|
<Dialog title="Reportes" size="drawer" onClose={onClose}>
|
||||||
<div id="reports-sidebar">
|
<div id="reports-sidebar">
|
||||||
<ReportContent reports={reports} />
|
<ReportContent reports={reports} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { profileRows } from "../lib/profile";
|
|||||||
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
|
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
|
||||||
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
|
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
import { Button, DialogFooter, DialogIntro, Field, FieldHint, Input, Select, Textarea } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
state: AppState;
|
state: AppState;
|
||||||
@@ -27,12 +28,12 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="tab-panels">
|
<div className="flex flex-col">
|
||||||
<div className="tab-panel active">
|
<div className="flex flex-col gap-4">
|
||||||
{config.allowDirect && !config.lb && (
|
{config.allowDirect && !config.lb && (
|
||||||
<label className="field">
|
<Field>
|
||||||
<span>URL del servidor de voz</span>
|
<span>URL del servidor de voz</span>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
@@ -43,17 +44,17 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
|||||||
setDraft({ ...draft, directUrl: event.target.value });
|
setDraft({ ...draft, directUrl: event.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<small>
|
<FieldHint>
|
||||||
{config.s2sUrl
|
{config.s2sUrl
|
||||||
? "El servidor fija esta dirección para todas las conversaciones."
|
? "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."}
|
: "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."}
|
||||||
</small>
|
</FieldHint>
|
||||||
</label>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<div className="field-row">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="field">
|
<Field>
|
||||||
<span>Voz</span>
|
<span>Voz</span>
|
||||||
<select
|
<Select
|
||||||
value={draft.voice}
|
value={draft.voice}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setDraft({ ...draft, voice: event.target.value });
|
setDraft({ ...draft, voice: event.target.value });
|
||||||
@@ -62,13 +63,13 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
|||||||
{VOICES.map((voice) => (
|
{VOICES.map((voice) => (
|
||||||
<option key={voice}>{voice}</option>
|
<option key={voice}>{voice}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px]">
|
||||||
<label className="field-head" htmlFor="noise-gate">
|
<label className="flex items-baseline justify-between" htmlFor="noise-gate">
|
||||||
Puerta de ruido
|
Puerta de ruido
|
||||||
<span className="field-value">
|
<span className="text-[12px] font-medium text-muted">
|
||||||
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
|
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -93,14 +94,14 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
|||||||
<span>−3 dB</span>
|
<span>−3 dB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small>
|
<FieldHint>
|
||||||
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
|
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
|
||||||
para desactivarla.
|
para desactivarla.
|
||||||
</small>
|
</FieldHint>
|
||||||
</div>
|
</div>
|
||||||
<label className="field">
|
<Field>
|
||||||
<span>Instrucciones</span>
|
<span>Instrucciones</span>
|
||||||
<textarea
|
<Textarea
|
||||||
rows={5}
|
rows={5}
|
||||||
value={config.primaryPrompt || draft.instructions}
|
value={config.primaryPrompt || draft.instructions}
|
||||||
readOnly={!!config.primaryPrompt}
|
readOnly={!!config.primaryPrompt}
|
||||||
@@ -108,12 +109,13 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
|||||||
setDraft({ ...draft, instructions: event.target.value });
|
setDraft({ ...draft, instructions: event.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
|
{config.primaryPrompt && <FieldHint>Las instrucciones están definidas por el servidor.</FieldHint>}
|
||||||
</label>
|
</Field>
|
||||||
<div className="field">
|
<div className="flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px]">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary wide"
|
className="w-full"
|
||||||
disabled={!LIVE_STATES.has(state.status)}
|
disabled={!LIVE_STATES.has(state.status)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
controller.changeSettings(draft);
|
controller.changeSettings(draft);
|
||||||
@@ -122,22 +124,22 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reiniciar conversación con esta configuración
|
Reiniciar conversación con esta configuración
|
||||||
</button>
|
</Button>
|
||||||
<small>
|
<FieldHint>
|
||||||
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
|
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
|
||||||
una nueva conversación.
|
una nueva conversación.
|
||||||
</small>
|
</FieldHint>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<DialogFooter>
|
||||||
<button className="btn secondary" type="button" onClick={onClose}>
|
<Button type="button" onClick={onClose}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</Button>
|
||||||
<button className="btn primary" type="submit">
|
<Button variant="primary" type="submit">
|
||||||
Guardar
|
Guardar
|
||||||
</button>
|
</Button>
|
||||||
</footer>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -157,10 +159,10 @@ export function RagDialog({ state, controller, onClose }: Props) {
|
|||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog title="Contexto RAG personalizado" onClose={onClose}>
|
<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
|
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.
|
se mantiene mientras esta página esté abierta.
|
||||||
</p>
|
</DialogIntro>
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -168,9 +170,9 @@ export function RagDialog({ state, controller, onClose }: Props) {
|
|||||||
setSaved(true);
|
setSaved(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="field">
|
<Field>
|
||||||
<span>Contexto RAG (texto libre)</span>
|
<span>Contexto RAG (texto libre)</span>
|
||||||
<textarea
|
<Textarea
|
||||||
rows={12}
|
rows={12}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
value={text}
|
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…"
|
placeholder="Pega o escribe el contexto que Vocero debe usar al responder…"
|
||||||
/>
|
/>
|
||||||
</label>
|
</Field>
|
||||||
<p className="tool-hint" role="status">
|
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
|
||||||
{saved
|
{saved
|
||||||
? "Contexto actualizado para esta conversación."
|
? "Contexto actualizado para esta conversación."
|
||||||
: state.rag
|
: state.rag
|
||||||
? `${String(state.rag.length)} caracteres de contexto cargados.`
|
? `${String(state.rag.length)} caracteres de contexto cargados.`
|
||||||
: "No hay contexto RAG cargado."}
|
: "No hay contexto RAG cargado."}
|
||||||
</p>
|
</p>
|
||||||
<footer className="modal-footer">
|
<DialogFooter>
|
||||||
<button
|
<Button
|
||||||
className="btn secondary"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setText("");
|
setText("");
|
||||||
@@ -199,12 +200,12 @@ export function RagDialog({ state, controller, onClose }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Vaciar
|
Vaciar
|
||||||
</button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button className="btn primary" type="submit">
|
<Button variant="primary" type="submit">
|
||||||
Enviar a la sesión
|
Enviar a la sesión
|
||||||
</button>
|
</Button>
|
||||||
</footer>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -217,9 +218,9 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<Dialog title="Herramientas" onClose={onClose}>
|
<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.
|
Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar.
|
||||||
</p>
|
</DialogIntro>
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -227,14 +228,17 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="tool-list">
|
<div className="flex flex-col">
|
||||||
<div className="tool-row">
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
<div className="tool-info">
|
<div className="flex min-w-0 flex-col gap-[3px]">
|
||||||
<span className="tool-name">Búsqueda web</span>
|
<span className="text-[15px] font-semibold text-foreground compact:text-[14px]">Búsqueda web</span>
|
||||||
<span className="tool-desc">Busca información en Google mediante Serper.</span>
|
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
|
||||||
|
Busca información en Google mediante Serper.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="switch">
|
<label className="relative inline-flex h-6 w-10 shrink-0 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
className="peer absolute inset-0 z-10 m-0 size-full cursor-pointer opacity-0"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Búsqueda web"
|
aria-label="Búsqueda web"
|
||||||
checked={draft.webSearch}
|
checked={draft.webSearch}
|
||||||
@@ -242,12 +246,15 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
setDraft({ ...draft, webSearch: event.target.checked });
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="field tools-key">
|
<Field className="mb-1">
|
||||||
<span>Clave de la API de búsqueda</span>
|
<span>Clave de la API de búsqueda</span>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={draft.searchKey}
|
value={draft.searchKey}
|
||||||
@@ -256,19 +263,23 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
setDraft({ ...draft, searchKey: event.target.value });
|
setDraft({ ...draft, searchKey: event.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<small>
|
<FieldHint>
|
||||||
{state.config.search
|
{state.config.search
|
||||||
? "La búsqueda está configurada en el servidor."
|
? "La búsqueda está configurada en el servidor."
|
||||||
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
|
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
|
||||||
</small>
|
</FieldHint>
|
||||||
</label>
|
</Field>
|
||||||
<div className="tool-row tool-row-sep">
|
<div className="mt-1 flex items-center justify-between gap-4 border-0 border-t border-solid border-line py-3">
|
||||||
<div className="tool-info">
|
<div className="flex min-w-0 flex-col gap-[3px]">
|
||||||
<span className="tool-name">Herramientas MCP cargadas</span>
|
<span className="text-[15px] font-semibold text-foreground compact:text-[14px]">
|
||||||
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
|
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>
|
||||||
</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
|
{state.toolsLoading
|
||||||
? "Cargando herramientas…"
|
? "Cargando herramientas…"
|
||||||
: state.toolsError ||
|
: state.toolsError ||
|
||||||
@@ -276,23 +287,23 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
? `${String(state.tools.length)} herramientas disponibles.`
|
? `${String(state.tools.length)} herramientas disponibles.`
|
||||||
: "No hay herramientas remotas disponibles.")}
|
: "No hay herramientas remotas disponibles.")}
|
||||||
</p>
|
</p>
|
||||||
<div className="agent-tools-list">
|
<div className="flex flex-col gap-2">
|
||||||
{state.tools.map((tool) => (
|
{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>
|
<strong className="block text-sm">{tool.name}</strong>
|
||||||
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
|
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<DialogFooter>
|
||||||
<button className="btn secondary" type="button" onClick={onClose}>
|
<Button type="button" onClick={onClose}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</Button>
|
||||||
<button className="btn primary" type="submit">
|
<Button variant="primary" type="submit">
|
||||||
Guardar
|
Guardar
|
||||||
</button>
|
</Button>
|
||||||
</footer>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -302,18 +313,23 @@ export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
|
|||||||
const name = profileName(state.profile) || "Usuario";
|
const name = profileName(state.profile) || "Usuario";
|
||||||
const rows = profileRows(state.profile);
|
const rows = profileRows(state.profile);
|
||||||
return (
|
return (
|
||||||
<Dialog title="Datos del usuario" className="user-profile-modal" onClose={onClose}>
|
<Dialog title="Datos del usuario" size="profile" onClose={onClose}>
|
||||||
<div className="user-profile-hero">
|
<div className="flex flex-col items-center gap-3.5 pt-1.5 text-center">
|
||||||
<div className="user-profile-avatar" aria-hidden="true">
|
<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)}
|
{initials(name)}
|
||||||
</div>
|
</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>
|
</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 }) => (
|
{rows.map(({ label, value, href }) => (
|
||||||
<div key={label} className="user-profile-row">
|
<div key={label} className="grid grid-cols-[90px_minmax(0,1fr)] gap-4 py-3">
|
||||||
<dt className="user-profile-label">{label}</dt>
|
<dt className="text-muted">{label}</dt>
|
||||||
<dd className="user-profile-value">{href ? <a href={href}>{value}</a> : value}</dd>
|
<dd className="m-0 min-w-0 wrap-anywhere">{href ? <a href={href}>{value}</a> : value}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -323,19 +339,25 @@ export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
|
|||||||
|
|
||||||
export function AboutDialog({ onClose }: { onClose: () => void }) {
|
export function AboutDialog({ onClose }: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog title="Acerca de" className="about-modal" onClose={onClose}>
|
<Dialog title="Acerca de" size="about" onClose={onClose}>
|
||||||
<p className="tools-intro">
|
<DialogIntro>
|
||||||
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
|
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
|
||||||
conversación.
|
conversación.
|
||||||
</p>
|
</DialogIntro>
|
||||||
<div className="about-pipeline">
|
<div className="border-0 border-t border-solid border-line pt-4">
|
||||||
<ol className="pipeline">
|
<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="pipe-endpoint">Tú hablas</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]">
|
||||||
<li className="pipe-stage">
|
Tú hablas
|
||||||
<span className="pipe-tag">STT</span>
|
</li>
|
||||||
<span className="pipe-job">transcribe tu 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]">
|
||||||
|
STT
|
||||||
|
</span>
|
||||||
|
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
|
||||||
|
transcribe tu voz
|
||||||
|
</span>
|
||||||
<a
|
<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"
|
href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -343,11 +365,15 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
|
|||||||
nvidia/parakeet-tdt-0.6b-v3
|
nvidia/parakeet-tdt-0.6b-v3
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li className="pipe-stage">
|
<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="pipe-tag">LLM</span>
|
<span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
|
||||||
<span className="pipe-job">genera una respuesta</span>
|
LLM
|
||||||
|
</span>
|
||||||
|
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
|
||||||
|
genera una respuesta
|
||||||
|
</span>
|
||||||
<a
|
<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"
|
href="https://huggingface.co/google/gemma-4-31B-it"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -355,11 +381,15 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
|
|||||||
google/gemma-4-31B-it
|
google/gemma-4-31B-it
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li className="pipe-stage">
|
<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="pipe-tag">TTS</span>
|
<span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
|
||||||
<span className="pipe-job">la convierte en voz</span>
|
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
|
<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"
|
href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -367,7 +397,9 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
|
|||||||
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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