feat(ui): the six native selects become the house controls
Owner's pick from the S7 mockups (2026-09-19): a short fixed set is a SegmentedToggle — the live search's result count, the table's rows per page, a download profile's mode (on its own row, so the three worded choices fit a phone) and audio format — and a longer set or the user's own list is a FilterSelect: the profile's quality (seven heights) and the download dialog's profile. A lint rule refuses a native <select> from now on (the canonical "FE control" row). The px-frozen size rules also caught up with a fraction: `text-[9.5px]` had slipped past the integer-only pattern in the Glass tuner, which is rem now.
This commit is contained in:
@@ -8,6 +8,8 @@ import { useConfirm } from "./confirmContext";
|
||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
|
||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||
|
||||
@@ -99,8 +101,6 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
|
||||
const set = (k: keyof DownloadSpec) => (e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial<DownloadSpec>);
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
|
||||
@@ -162,51 +162,59 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("downloads.profiles.mode")}
|
||||
</label>
|
||||
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
|
||||
<option value="av">{t("downloads.profiles.modeAv")}</option>
|
||||
<option value="v">{t("downloads.profiles.modeV")}</option>
|
||||
<option value="a">{t("downloads.profiles.modeA")}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Mode on its own row: three worded choices need the width a half column lacks on a
|
||||
phone. Segmented for the short fixed sets, a menu for the seven heights (owner's pick). */}
|
||||
<div>
|
||||
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
||||
{t("downloads.profiles.mode")}
|
||||
</span>
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("downloads.profiles.mode")}
|
||||
size="md"
|
||||
fullWidth
|
||||
value={spec.mode}
|
||||
onChange={(mode) => patch({ mode })}
|
||||
options={[
|
||||
{ value: "av", label: t("downloads.profiles.modeAv") },
|
||||
{ value: "v", label: t("downloads.profiles.modeV") },
|
||||
{ value: "a", label: t("downloads.profiles.modeA") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{spec.mode !== "a" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
||||
{t("downloads.profiles.quality")}
|
||||
</label>
|
||||
<select
|
||||
value={spec.max_height ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })
|
||||
}
|
||||
className={inputCls}
|
||||
>
|
||||
{HEIGHTS.map((h) => (
|
||||
<option key={h ?? "best"} value={h ?? ""}>
|
||||
{h ? `${h}p` : t("downloads.profiles.best")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
<FilterSelect
|
||||
label={t("downloads.profiles.quality")}
|
||||
hideLabel
|
||||
selected={[String(spec.max_height ?? "")]}
|
||||
onChange={([v]) => v !== undefined && patch({ max_height: v ? Number(v) : null })}
|
||||
options={HEIGHTS.map((h) => ({
|
||||
value: String(h ?? ""),
|
||||
label: h ? `${h}p` : t("downloads.profiles.best"),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
||||
{t("downloads.profiles.audioFormat")}
|
||||
</label>
|
||||
</span>
|
||||
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
|
||||
unreliably as a bare file, and the module's purpose is in-browser playback. */}
|
||||
<select
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("downloads.profiles.audioFormat")}
|
||||
size="md"
|
||||
value={spec.audio_format ?? "m4a"}
|
||||
onChange={set("audio_format")}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="m4a">M4A</option>
|
||||
<option value="mp3">MP3</option>
|
||||
</select>
|
||||
onChange={(audio_format) => patch({ audio_format })}
|
||||
options={[
|
||||
{ value: "m4a", label: "M4A" },
|
||||
{ value: "mp3", label: "MP3" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Container/codec are no longer user-selectable: every download is clamped to the
|
||||
|
||||
Reference in New Issue
Block a user