feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)

- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
This commit is contained in:
2026-09-24 23:48:22 +02:00
parent 81936e9f56
commit dac9d89f91
63 changed files with 923 additions and 416 deletions
+14 -22
View File
@@ -10,10 +10,11 @@ import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input, InnerInput } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -56,7 +57,8 @@ function UserShare({ job }: { job: DownloadJob }) {
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
<div className="relative">
<input
<Input
className="w-full"
value={q}
onChange={(e) => {
setQ(e.target.value);
@@ -66,7 +68,6 @@ function UserShare({ job }: { job: DownloadJob }) {
// Close on blur, but after a tick so a click on a dropdown row still registers.
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.share.pickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
@@ -144,11 +145,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<input
readOnly
value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
/>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
@@ -207,14 +204,15 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
</span>
))}
<div className="flex-1" />
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={link.allow_download} onChange={() => toggle.mutate()} />
<CheckRow checked={link.allow_download} onChange={() => toggle.mutate()}>
{t("downloads.share.allowDownload")}
</label>
</CheckRow>
</div>
{editPass && (
<div className="flex items-center gap-2">
<input
<Input
className="flex-1"
size="sm"
type="password"
autoComplete="new-password"
value={newPass}
@@ -225,7 +223,6 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
placeholder={t(
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
)}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
size="xs"
@@ -327,14 +324,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
{creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={allowDownload}
onChange={(e) => setAllowDownload(e.target.checked)}
/>
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
{t("downloads.share.allowDownload")}
</label>
</CheckRow>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted">{t("downloads.share.expiry")}:</span>
{EXPIRY_DAYS.map((d) => (
@@ -351,12 +343,12 @@ function LinkShare({ job }: { job: DownloadJob }) {
</Button>
))}
</div>
<input
<Input
className="w-full"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("downloads.share.password")}
className={inputCls}
autoComplete="new-password"
/>
<div className="flex justify-end gap-2">