Refine settings UI controls

This commit is contained in:
rishikanthc
2026-04-26 21:24:21 -07:00
parent 75eee5f94a
commit 76fbd0bd0d
7 changed files with 401 additions and 61 deletions

View File

@@ -10,7 +10,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&family=Literata:ital,opsz,wght@0,7..72,200..900;1,7..72,200..900&display=swap"
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Literata:ital,opsz,wght@0,7..72,200..900;1,7..72,200..900&display=swap"
rel="stylesheet">
</head>

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Check, X } from "lucide-react";
import { AppButton, IconButton } from "@/shared/ui/Button";
import { Select, type SelectOption } from "@/shared/ui/Select";
import {
defaultProfileParams,
familyForModel,
@@ -60,11 +61,12 @@ export function ASRProfileDialog({ open, profile, models, onClose, onSave }: ASR
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const modelOptions = useMemo(() => {
const modelOptions = useMemo<SelectOption[]>(() => {
const source = models.length ? models : fallbackModels;
return source.map((model) => ({
value: model.id,
label: `${model.name}${model.installed ? "" : " (downloads on use)"}`,
label: model.name,
description: model.installed ? "Installed locally" : "Downloads on use",
}));
}, [models]);
@@ -202,15 +204,8 @@ function TextField({ label, value, onChange, placeholder }: { label: string; val
);
}
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
return (
<label className="scr-control">
<span>{label}</span>
<select className="scr-select" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
);
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: SelectOption[]; onChange: (value: string) => void }) {
return <Select label={label} value={value} options={options} onChange={onChange} />;
}
function NumberField({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Edit3, Plus, Settings2, Star, Trash2 } from "lucide-react";
import { Edit3, Plus, Trash2 } from "lucide-react";
import { Sidebar } from "@/features/home/components/HomePage";
import { AppButton, IconButton } from "@/shared/ui/Button";
import { ConfirmDialog } from "@/shared/ui/ConfirmDialog";
import { EmptyState } from "@/shared/ui/EmptyState";
import { ASRProfileDialog } from "../components/ASRProfileDialog";
import {
@@ -23,6 +24,8 @@ export function Settings() {
const [error, setError] = useState("");
const [editingProfile, setEditingProfile] = useState<TranscriptionProfile | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [profileToDelete, setProfileToDelete] = useState<TranscriptionProfile | null>(null);
const [deleting, setDeleting] = useState(false);
const [models, setModels] = useState<TranscriptionModel[]>([]);
const loadProfiles = useCallback(async () => {
@@ -59,10 +62,19 @@ export function Settings() {
await loadProfiles();
};
const handleDelete = async (profile: TranscriptionProfile) => {
if (!window.confirm(`Delete "${profile.name}"?`)) return;
await deleteProfile(profile.id);
await loadProfiles();
const confirmDelete = async () => {
if (!profileToDelete) return;
setDeleting(true);
setError("");
try {
await deleteProfile(profileToDelete.id);
setProfileToDelete(null);
await loadProfiles();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not delete profile.");
} finally {
setDeleting(false);
}
};
return (
@@ -118,7 +130,7 @@ export function Settings() {
setEditingProfile(profile);
setDialogOpen(true);
}}
onDelete={() => void handleDelete(profile)}
onDelete={() => setProfileToDelete(profile)}
/>
))}
</div>
@@ -141,6 +153,17 @@ export function Settings() {
}}
onSave={handleSave}
/>
<ConfirmDialog
open={Boolean(profileToDelete)}
title="Delete profile?"
description={profileToDelete ? `This will remove "${profileToDelete.name}" from your saved ASR profiles.` : ""}
confirmLabel="Delete"
busy={deleting}
onCancel={() => {
if (!deleting) setProfileToDelete(null);
}}
onConfirm={() => void confirmDelete()}
/>
</div>
);
}
@@ -156,15 +179,11 @@ function ProfileRow({ profile, isDefault, onEdit, onDelete }: { profile: Transcr
return (
<article className="scr-profile-row">
<button className="scr-profile-main" type="button" onClick={onEdit}>
<div className="scr-profile-icon">
<Settings2 size={18} aria-hidden="true" />
</div>
<div className="scr-profile-copy">
<div className="scr-profile-title-row">
<h3 className="scr-profile-title">{profile.name}</h3>
{isDefault ? (
<span className="scr-profile-badge">
<Star size={12} aria-hidden="true" />
Default
</span>
) : null}

View File

@@ -74,10 +74,10 @@
--color-brand-900: var(--brand-900);
--color-brand-950: var(--brand-950);
--font-sans: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-display: 'Poppins', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-sans: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-display: 'Manrope', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-inter: 'Poppins', sans-serif;
--font-inter: 'Manrope', sans-serif;
--font-reading: 'Literata', serif;
/* For Transcripts */

View File

@@ -0,0 +1,41 @@
import { AlertTriangle, X } from "lucide-react";
import { AppButton, IconButton } from "./Button";
type ConfirmDialogProps = {
open: boolean;
title: string;
description: string;
confirmLabel: string;
busy?: boolean;
onCancel: () => void;
onConfirm: () => void;
};
export function ConfirmDialog({ open, title, description, confirmLabel, busy, onCancel, onConfirm }: ConfirmDialogProps) {
if (!open) return null;
return (
<div className="scr-modal-backdrop" role="presentation">
<section className="scr-confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="scr-confirm-title" aria-describedby="scr-confirm-copy">
<header className="scr-confirm-header">
<span className="scr-confirm-mark" aria-hidden="true">
<AlertTriangle size={18} />
</span>
<IconButton label="Close confirmation" onClick={onCancel}>
<X size={17} aria-hidden="true" />
</IconButton>
</header>
<div className="scr-confirm-body">
<h2 id="scr-confirm-title" className="scr-confirm-title">{title}</h2>
<p id="scr-confirm-copy" className="scr-confirm-copy">{description}</p>
</div>
<footer className="scr-confirm-footer">
<AppButton variant="secondary" onClick={onCancel}>Cancel</AppButton>
<AppButton className="scr-button-danger" onClick={onConfirm} disabled={busy}>
{busy ? "Deleting..." : confirmLabel}
</AppButton>
</footer>
</section>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useId, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export type SelectOption = {
value: string;
label: string;
description?: string;
};
type SelectProps = {
label?: string;
value: string;
options: SelectOption[];
onChange: (value: string) => void;
className?: string;
};
export function Select({ label, value, options, onChange, className }: SelectProps) {
const [open, setOpen] = useState(false);
const id = useId();
const containerRef = useRef<HTMLDivElement>(null);
const selected = options.find((option) => option.value === value) || options[0];
useEffect(() => {
if (!open) return;
const closeOnOutside = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("pointerdown", closeOnOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
const choose = (nextValue: string) => {
onChange(nextValue);
setOpen(false);
};
return (
<div className={cn("scr-select-field", className)} ref={containerRef}>
{label ? <span className="scr-select-label">{label}</span> : null}
<button
id={id}
className="scr-select-trigger"
type="button"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span className="scr-select-value">
<span>{selected?.label || "Select"}</span>
{selected?.description ? <small>{selected.description}</small> : null}
</span>
<ChevronDown className="scr-select-chevron" size={16} aria-hidden="true" />
</button>
{open ? (
<div className="scr-select-menu" role="listbox" aria-labelledby={id}>
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
className="scr-select-option"
data-active={active}
type="button"
role="option"
aria-selected={active}
onClick={() => choose(option.value)}
>
<span>
<span>{option.label}</span>
{option.description ? <small>{option.description}</small> : null}
</span>
{active ? <Check size={16} aria-hidden="true" /> : null}
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -93,6 +93,7 @@
background: var(--scr-surface-canvas);
color: var(--scr-text-primary);
font-family: var(--scr-font-sans);
font-size: 15px;
line-height: var(--scr-line-base);
}
@@ -654,12 +655,14 @@
.scr-input {
width: 100%;
height: 42px;
height: 40px;
border: 1px solid var(--scr-border-strong);
border-radius: var(--scr-radius-sm);
background: var(--scr-surface-panel);
color: var(--scr-text-primary);
font-size: 0.9375rem;
font-size: 0.875rem;
font-weight: 500;
line-height: var(--scr-line-base);
outline: none;
padding: 0 var(--scr-space-3);
transition: border-color 160ms ease, box-shadow 160ms ease;
@@ -764,7 +767,7 @@
.scr-settings-title {
color: var(--scr-text-primary);
font-size: 1rem;
font-size: 1.0625rem;
font-weight: 600;
line-height: var(--scr-line-tight);
}
@@ -828,7 +831,7 @@
.scr-settings-heading {
color: var(--scr-text-primary);
font-size: 1.125rem;
font-size: 1.25rem;
font-weight: 600;
line-height: var(--scr-line-tight);
}
@@ -837,8 +840,8 @@
max-width: 560px;
margin-top: 0.375rem;
color: var(--scr-text-secondary);
font-size: 0.8125rem;
line-height: var(--scr-line-relaxed);
font-size: 0.875rem;
line-height: 1.58;
}
.scr-settings-new-profile {
@@ -858,7 +861,7 @@
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--scr-space-3);
min-height: 72px;
min-height: 70px;
border: 1px solid var(--scr-border-subtle);
border-radius: var(--scr-radius-md);
background: var(--scr-surface-raised);
@@ -876,9 +879,8 @@
.scr-profile-main {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
align-items: center;
gap: var(--scr-space-3);
min-width: 0;
border: 0;
background: transparent;
@@ -888,16 +890,6 @@
text-align: left;
}
.scr-profile-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--scr-radius-sm);
background: var(--scr-brand-muted);
color: var(--scr-brand-solid);
}
.scr-profile-copy {
min-width: 0;
}
@@ -912,7 +904,7 @@
.scr-profile-title {
overflow: hidden;
color: var(--scr-text-primary);
font-size: 0.9375rem;
font-size: 0.96875rem;
font-weight: 600;
line-height: var(--scr-line-tight);
text-overflow: ellipsis;
@@ -922,16 +914,16 @@
.scr-profile-badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
border: 1px solid var(--scr-brand-border);
height: 18px;
border: 1px solid color-mix(in srgb, var(--scr-brand-solid) 20%, transparent);
border-radius: 999px;
background: var(--scr-brand-muted);
background: color-mix(in srgb, var(--scr-brand-solid) 8%, transparent);
color: var(--scr-brand-ink);
flex: 0 0 auto;
font-size: 0.6875rem;
font-size: 0.625rem;
font-weight: 500;
padding: 0 var(--scr-space-2);
line-height: 1;
padding: 0 0.4375rem;
}
.dark .scr-profile-badge {
@@ -1033,7 +1025,7 @@
.scr-modal-title {
color: var(--scr-text-primary);
font-size: 1.25rem;
font-size: 1.375rem;
font-weight: 600;
line-height: var(--scr-line-tight);
}
@@ -1066,7 +1058,7 @@
.scr-settings-section-title {
color: var(--scr-text-primary);
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 600;
line-height: var(--scr-line-tight);
}
@@ -1081,7 +1073,7 @@
display: grid;
gap: 0.375rem;
color: var(--scr-text-secondary);
font-size: 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
line-height: var(--scr-line-tight);
}
@@ -1108,7 +1100,6 @@
font-weight: 600;
}
.scr-select,
.scr-textarea {
width: 100%;
border: 1px solid var(--scr-border-strong);
@@ -1122,23 +1113,159 @@
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.scr-select {
height: 42px;
padding: 0 var(--scr-space-3);
}
.scr-textarea {
min-height: 82px;
padding: var(--scr-space-3);
resize: vertical;
}
.scr-select:focus-visible,
.scr-textarea:focus-visible {
border-color: var(--scr-brand-solid);
box-shadow: 0 0 0 3px var(--scr-brand-muted);
}
.scr-select-field {
position: relative;
display: grid;
gap: 0.375rem;
min-width: 0;
}
.scr-select-label {
color: var(--scr-text-secondary);
font-size: 0.8125rem;
font-weight: 500;
line-height: var(--scr-line-tight);
}
.scr-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--scr-space-3);
width: 100%;
min-height: 40px;
border: 1px solid var(--scr-border-strong);
border-radius: var(--scr-radius-sm);
background: var(--scr-surface-panel);
color: var(--scr-text-primary);
cursor: pointer;
padding: 0.4375rem var(--scr-space-3);
text-align: left;
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.scr-select-trigger:hover,
.scr-select-trigger:focus-visible,
.scr-select-trigger[aria-expanded="true"] {
border-color: var(--scr-brand-border);
box-shadow: 0 0 0 3px var(--scr-brand-muted);
outline: none;
}
.scr-select-value {
display: grid;
min-width: 0;
color: var(--scr-text-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
}
.scr-select-value span,
.scr-select-value small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scr-select-value small {
margin-top: 0.125rem;
color: var(--scr-text-tertiary);
font-size: 0.75rem;
font-weight: 400;
}
.scr-select-chevron {
flex: 0 0 auto;
color: var(--scr-text-tertiary);
transition: transform 160ms ease;
}
.scr-select-trigger[aria-expanded="true"] .scr-select-chevron {
transform: rotate(180deg);
}
.scr-select-menu {
position: absolute;
top: calc(100% + 0.375rem);
right: 0;
left: 0;
z-index: 120;
display: grid;
gap: 0.25rem;
max-height: 280px;
overflow: auto;
border: 1px solid var(--scr-border-strong);
border-radius: var(--scr-radius-lg);
background: var(--scr-surface-raised);
box-shadow: var(--scr-shadow-float);
padding: 0.375rem;
}
.scr-select-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--scr-space-3);
min-height: 42px;
border: 0;
border-radius: var(--scr-radius-sm);
background: transparent;
color: var(--scr-text-secondary);
cursor: pointer;
padding: 0.5rem 0.625rem;
text-align: left;
}
.scr-select-option > span {
display: grid;
min-width: 0;
}
.scr-select-option > span > span,
.scr-select-option small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scr-select-option > span > span {
color: var(--scr-text-primary);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
}
.scr-select-option small {
margin-top: 0.125rem;
color: var(--scr-text-secondary);
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.25;
}
.scr-select-option:hover,
.scr-select-option:focus-visible,
.scr-select-option[data-active="true"] {
background: var(--scr-surface-muted);
outline: none;
}
.scr-select-option[data-active="true"] {
color: var(--scr-text-primary);
}
.scr-check-row {
position: relative;
display: flex;
@@ -1203,6 +1330,70 @@
padding: 0 var(--scr-space-2);
}
.scr-button-danger {
background: var(--scr-color-danger);
color: var(--scr-color-white);
}
.scr-button-danger:hover,
.scr-button-danger:focus-visible {
background: color-mix(in srgb, var(--scr-color-danger) 88%, var(--scr-color-black));
color: var(--scr-color-white);
}
.scr-confirm-modal {
width: min(100%, 420px);
overflow: hidden;
border: 1px solid var(--scr-border-strong);
border-radius: var(--scr-radius-xl);
background: var(--scr-surface-raised);
box-shadow: var(--scr-shadow-float);
}
.scr-confirm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--scr-space-4) var(--scr-space-4) 0;
}
.scr-confirm-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: color-mix(in srgb, var(--scr-color-danger) 10%, transparent);
color: var(--scr-color-danger);
}
.scr-confirm-body {
display: grid;
gap: var(--scr-space-2);
padding: var(--scr-space-3) var(--scr-space-5) var(--scr-space-5);
}
.scr-confirm-title {
color: var(--scr-text-primary);
font-size: 1.125rem;
font-weight: 600;
line-height: var(--scr-line-tight);
}
.scr-confirm-copy {
color: var(--scr-text-secondary);
font-size: 0.875rem;
line-height: 1.55;
}
.scr-confirm-footer {
display: flex;
justify-content: flex-end;
gap: var(--scr-space-2);
border-top: 1px solid var(--scr-border-subtle);
padding: var(--scr-space-4) var(--scr-space-5);
}
@keyframes scr-shimmer {
to {
background-position: -220% 0;