mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release.
This commit is contained in:
1 parent
d98e85cd03
commit
4f09c3f862
314 files changed
+26801
-4419
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/settings/api` mobile card-list parity.
|
||||
*
|
||||
* The section ships three tables: endpoint catalogue, active tokens,
|
||||
* revoked tokens. Each table now renders twice:
|
||||
* - desktop `<table>` wrapped in `hidden md:block`
|
||||
* - mobile `<ul>` tagged `md:hidden`
|
||||
*
|
||||
* This file pins both surfaces against regressions so a future tweak
|
||||
* can't strip the mobile card list (and force a 393 CSS-px viewport
|
||||
* back into horizontal scroll).
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/api",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const sampleTokens = [
|
||||
{
|
||||
id: "tok-active",
|
||||
name: "iOS app",
|
||||
permissions: ["ingest:medication"],
|
||||
lastUsedAt: "2026-05-08T12:00:00Z",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-01T08:00:00Z",
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
id: "tok-revoked",
|
||||
name: "Old laptop",
|
||||
permissions: ["ingest:weight"],
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
createdAt: "2026-04-20T08:00:00Z",
|
||||
revoked: true,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: sampleTokens,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiSection } from "../api-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ApiSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiSection — responsive dual-rendering", () => {
|
||||
it("renders the endpoint table behind `hidden md:block`", () => {
|
||||
const html = render();
|
||||
// The endpoint catalogue is the first table; its desktop wrapper
|
||||
// must carry the `hidden md:block` pair so phones never see it.
|
||||
expect(html).toMatch(/<div[^>]*\bhidden\b[^>]*md:block[^>]*>[\s\S]*<table/);
|
||||
});
|
||||
|
||||
it("exposes a mobile card list for the endpoint catalogue", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-api-endpoints-mobile-list"');
|
||||
// The mobile <ul> carries `md:hidden` (class attribute precedes
|
||||
// `data-testid` in the rendered markup, so we check both anchors
|
||||
// independently).
|
||||
const endpointMatch = html.match(
|
||||
/<ul[^>]*data-testid="settings-api-endpoints-mobile-list"[^>]*>/,
|
||||
);
|
||||
expect(endpointMatch).not.toBeNull();
|
||||
expect(endpointMatch![0]).toContain("md:hidden");
|
||||
});
|
||||
|
||||
it("exposes a mobile card list for the active tokens table", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-api-tokens-mobile-list"');
|
||||
const tokensMatch = html.match(
|
||||
/<ul[^>]*data-testid="settings-api-tokens-mobile-list"[^>]*>/,
|
||||
);
|
||||
expect(tokensMatch).not.toBeNull();
|
||||
expect(tokensMatch![0]).toContain("md:hidden");
|
||||
// Every column from the desktop table is reachable as labelled
|
||||
// copy in the mobile card body.
|
||||
expect(html).toContain("iOS app");
|
||||
expect(html).toContain("ingest:medication");
|
||||
});
|
||||
|
||||
it("mobile card lists carry no overflow-x-auto wrapper", () => {
|
||||
const html = render();
|
||||
// Both mobile <ul> blocks must contain zero `overflow-x-auto`. That
|
||||
// was the original v1.4.27 CF-19 regression — the table wrapper
|
||||
// forced horizontal scroll on phones for content that easily fits
|
||||
// a card column.
|
||||
const endpointMatch = html.match(
|
||||
/<ul[^>]*settings-api-endpoints-mobile-list[^>]*>([\s\S]*?)<\/ul>/,
|
||||
);
|
||||
expect(endpointMatch).not.toBeNull();
|
||||
expect(endpointMatch![1]).not.toContain("overflow-x-auto");
|
||||
|
||||
const tokensMatch = html.match(
|
||||
/<ul[^>]*settings-api-tokens-mobile-list[^>]*>([\s\S]*?)<\/ul>/,
|
||||
);
|
||||
expect(tokensMatch).not.toBeNull();
|
||||
expect(tokensMatch![1]).not.toContain("overflow-x-auto");
|
||||
});
|
||||
|
||||
it("preserves the desktop tables verbatim", () => {
|
||||
const html = render();
|
||||
// The desktop tables must still mount — we did not delete them,
|
||||
// only paired them with the mobile fallback.
|
||||
expect(html).toContain("<table");
|
||||
// Active-token cell content shows up inside <td> elements as it did
|
||||
// before this change.
|
||||
expect(html).toMatch(/<td[^>]*>iOS app<\/td>/);
|
||||
});
|
||||
});
|
||||
@@ -284,7 +284,7 @@ export function AboutSection() {
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
@@ -53,15 +54,10 @@ interface PasskeyInfo {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared class string for native `<select>` elements inside the Account
|
||||
* card. Mirrors the visual contract of `<Input>` and shadcn
|
||||
* `<SelectTrigger>` (`h-9`, identical border/focus tokens) so gender,
|
||||
* language, and any future native dropdown render at the same height
|
||||
* and width as every other input in the form.
|
||||
*/
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
|
||||
// retired; the shared `<NativeSelect>` primitive owns the visual
|
||||
// contract now. Existing `<select className={NATIVE_SELECT_CLASS}>`
|
||||
// call sites in this file swapped to `<NativeSelect>` below.
|
||||
|
||||
export function AccountSection() {
|
||||
const { t, locale, setLocale } = useTranslations();
|
||||
@@ -355,7 +351,12 @@ export function AccountSection() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("settings.username")}</Label>
|
||||
<Input id="username" value={user.username} disabled />
|
||||
<Input
|
||||
id="username"
|
||||
value={user.username}
|
||||
disabled
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
@@ -366,6 +367,8 @@ export function AccountSection() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
maxLength={320}
|
||||
autoComplete="email"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,16 +376,15 @@ export function AccountSection() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">{t("settings.gender")}</Label>
|
||||
<select
|
||||
<NativeSelect
|
||||
id="gender"
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
className={NATIVE_SELECT_CLASS}
|
||||
>
|
||||
<option value="">{t("settings.genderNone")}</option>
|
||||
<option value="MALE">{t("settings.genderMale")}</option>
|
||||
<option value="FEMALE">{t("settings.genderFemale")}</option>
|
||||
</select>
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.genderHint")}
|
||||
</p>
|
||||
@@ -392,6 +394,8 @@ export function AccountSection() {
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
enterKeyHint="next"
|
||||
value={heightCm}
|
||||
onChange={(e) => setHeightCm(e.target.value)}
|
||||
placeholder="175"
|
||||
@@ -402,13 +406,12 @@ export function AccountSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date of birth pairs with the height/gender block above —
|
||||
all three are "biological profile" data the app uses for
|
||||
BMI + BP target calculations. Language is handled as its
|
||||
own row below: it's a UI preference (cookie-backed),
|
||||
not a profile attribute, so v1.4.19 A6 lifted it out of
|
||||
the dob/language pair where users mistook it for a
|
||||
per-account locale setting. */}
|
||||
{/* Date of birth + language share one paired grid row so the
|
||||
profile form keeps a single rhythm (every row two cells
|
||||
wide on sm+). Date of birth is the bottom of the
|
||||
biological-profile block; language is the only UI
|
||||
preference on this card. They sit together to close the
|
||||
"single-cell row" gap that broke the form's grid. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
|
||||
@@ -422,29 +425,27 @@ export function AccountSection() {
|
||||
{t("settings.dateOfBirthHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language-select">{t("settings.language")}</Label>
|
||||
<NativeSelect
|
||||
id="language-select"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{localeLabels[loc as Locale]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.languageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimezonePicker value={timezone} onChange={setTimezone} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language-select">{t("settings.language")}</Label>
|
||||
<select
|
||||
id="language-select"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
className={`${NATIVE_SELECT_CLASS} sm:max-w-xs`}
|
||||
>
|
||||
{locales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{localeLabels[loc as Locale]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.languageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -461,7 +462,7 @@ export function AccountSection() {
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -485,7 +486,7 @@ export function AccountSection() {
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
{passkeyLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -609,9 +610,9 @@ export function AccountSection() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-3">
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
@@ -621,7 +622,7 @@ export function AccountSection() {
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("settings.newPassword")}
|
||||
</Label>
|
||||
@@ -631,7 +632,7 @@ export function AccountSection() {
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
@@ -709,7 +710,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t("settings.passkeys")}</h3>
|
||||
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
@@ -719,7 +720,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
@@ -832,11 +833,17 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</p>
|
||||
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
|
||||
plain text to an outline Badge so the mobile card
|
||||
list reads consistent with the desktop table's
|
||||
"Single-device / Multi-device" column. Sits on the
|
||||
same chip row as the backup status and date so all
|
||||
metadata reads as a single horizontal stride. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
|
||||
@@ -281,7 +281,7 @@ function DataResetCard() {
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={deleting}>
|
||||
{deleting ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -31,8 +31,9 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -249,7 +250,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" />
|
||||
@@ -341,12 +342,14 @@ function ProviderStatusBadges({
|
||||
}
|
||||
|
||||
/**
|
||||
* The single Pulldown that drives every form below it. Uses a native
|
||||
* `<select>` so the SSR-only settings test renders deterministically
|
||||
* without a portal-based Radix tree. Mobile: full-width, height matched
|
||||
* to the shared 36-px input contract used everywhere else in Settings
|
||||
* (`<Input>` is `h-9`); tap target stays comfortable thanks to the
|
||||
* full-width chevron region.
|
||||
* The single Pulldown that drives every form below it. Uses the
|
||||
* shared `<NativeSelect>` primitive so the SSR-only settings test
|
||||
* renders deterministically without a portal-based Radix tree, and so
|
||||
* the visual contract stays uniform with the other settings pickers
|
||||
* that landed behind the same primitive in MB7 / CF-52. Mobile:
|
||||
* full-width, height matched to the shared 36 px input contract used
|
||||
* everywhere else in Settings; tap target stays comfortable thanks to
|
||||
* the full-width chevron region.
|
||||
*/
|
||||
function ActiveProviderSelect({
|
||||
value,
|
||||
@@ -367,7 +370,7 @@ function ActiveProviderSelect({
|
||||
<Label htmlFor="ai-active-provider-select">
|
||||
{t("settings.ai.activeProviderLabel")}
|
||||
</Label>
|
||||
<select
|
||||
<NativeSelect
|
||||
id="ai-active-provider-select"
|
||||
data-testid="ai-active-provider-select"
|
||||
value={value}
|
||||
@@ -375,14 +378,14 @@ function ActiveProviderSelect({
|
||||
const next = e.target.value;
|
||||
if (isProviderType(next)) onChange(next);
|
||||
}}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-3 text-sm sm:max-w-md"
|
||||
className="mt-1 sm:max-w-md"
|
||||
>
|
||||
{PROVIDER_TYPES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`settings.ai.providerSelect.${p}` as const)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -820,12 +823,12 @@ function OpenAIProviderForm({
|
||||
<Label htmlFor="ai-openai-model">
|
||||
{t("settings.ai.openai.modelSelect")}
|
||||
</Label>
|
||||
<select
|
||||
<NativeSelect
|
||||
id="ai-openai-model"
|
||||
data-testid="ai-openai-model"
|
||||
value={modelChoice}
|
||||
onChange={(e) => setModelChoice(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
className="mt-1"
|
||||
>
|
||||
<option value="">{t("settings.ai.modelOptionDefault")}</option>
|
||||
{OPENAI_MODEL_PRESETS.map((m) => (
|
||||
@@ -836,7 +839,7 @@ function OpenAIProviderForm({
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
{t("settings.ai.openai.modelOptionCustom")}
|
||||
</option>
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{modelChoice === CUSTOM_MODEL_SENTINEL && (
|
||||
@@ -889,7 +892,7 @@ function OpenAIProviderForm({
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -1005,11 +1008,11 @@ function AnthropicProviderForm({
|
||||
<Label htmlFor="ai-anthropic-model">
|
||||
{t("settings.ai.modelLabel")}
|
||||
</Label>
|
||||
<select
|
||||
<NativeSelect
|
||||
id="ai-anthropic-model"
|
||||
value={modelChoice}
|
||||
onChange={(e) => setModelChoice(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
className="mt-1"
|
||||
>
|
||||
<option value="">{t("settings.ai.modelOptionDefault")}</option>
|
||||
{ANTHROPIC_MODEL_PRESETS.map((m) => (
|
||||
@@ -1020,7 +1023,7 @@ function AnthropicProviderForm({
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
{t("settings.ai.modelOptionCustom")}
|
||||
</option>
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{modelChoice === CUSTOM_MODEL_SENTINEL && (
|
||||
@@ -1045,7 +1048,7 @@ function AnthropicProviderForm({
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -1163,11 +1166,11 @@ function LocalProviderForm({
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ai-local-model">{t("settings.ai.modelLabel")}</Label>
|
||||
<select
|
||||
<NativeSelect
|
||||
id="ai-local-model"
|
||||
value={modelChoice}
|
||||
onChange={(e) => setModelChoice(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
className="mt-1"
|
||||
>
|
||||
<option value="">{t("settings.ai.modelOptionDefault")}</option>
|
||||
{LOCAL_MODEL_PRESETS.map((m) => (
|
||||
@@ -1178,7 +1181,7 @@ function LocalProviderForm({
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
{t("settings.ai.modelOptionCustom")}
|
||||
</option>
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
{modelChoice === CUSTOM_MODEL_SENTINEL && (
|
||||
<div>
|
||||
@@ -1202,7 +1205,7 @@ function LocalProviderForm({
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -1454,7 +1457,7 @@ function FallbackChainCard({
|
||||
data-testid="ai-fallback-chain-save"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -1507,18 +1510,18 @@ function AddProviderControl({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
<NativeSelect
|
||||
aria-label={t("settings.ai.providerChain.addProvider")}
|
||||
value={picked}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="bg-background border-input h-9 rounded-md border px-2 text-sm"
|
||||
className="w-auto"
|
||||
>
|
||||
{addable.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`settings.ai.providerChain.types.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1659,7 +1662,7 @@ function RuntimeActionsRow({
|
||||
data-testid="ai-test-active-provider"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -1673,7 +1676,7 @@ function RuntimeActionsRow({
|
||||
disabled={regen}
|
||||
>
|
||||
{regen ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
|
||||
@@ -58,6 +58,21 @@ export function ApiSection() {
|
||||
function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.27 MB5 — dual-render: <md gets a stacked card list, md+
|
||||
// keeps the original table. Mobile cards wrap inside their box and
|
||||
// never trigger horizontal scroll, mirroring the pattern from
|
||||
// `<ApiTokenOverviewSection>`. The endpoint catalogue currently
|
||||
// ships a single row but stays array-driven so adding new rows
|
||||
// remains a one-line edit on both surfaces.
|
||||
const endpoints = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/ingest/medication",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -70,7 +85,8 @@ function ApiEndpointsCard() {
|
||||
{t("settings.apiEndpointsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
@@ -89,19 +105,52 @@ function ApiEndpointsCard() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-3 py-2 font-medium">POST</td>
|
||||
<td className="px-3 py-2 font-mono">/api/ingest/medication</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
Authorization: Bearer hlk_...
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
{`{ "medicationName": "...", "scheduledFor": "..." }`}
|
||||
</td>
|
||||
</tr>
|
||||
{endpoints.map((endpoint) => (
|
||||
<tr key={`${endpoint.method} ${endpoint.path}`}>
|
||||
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
|
||||
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each endpoint stacks its columns vertically
|
||||
with a labelled meta row. No horizontal scroll. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="settings-api-endpoints-mobile-list"
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
<li
|
||||
key={`${endpoint.method} ${endpoint.path}`}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="font-mono break-all">{endpoint.path}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</p>
|
||||
<code className="font-mono break-all">{endpoint.auth}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</p>
|
||||
<code className="block font-mono break-all">
|
||||
{endpoint.example}
|
||||
</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,7 +259,7 @@ function ApiTokensCard() {
|
||||
size="sm"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -236,7 +285,8 @@ function ApiTokensCard() {
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[860px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
@@ -342,6 +392,103 @@ function ApiTokensCard() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each row stacks its columns vertically
|
||||
with explicit labels so the data is readable inside a
|
||||
narrow viewport. The revoke action stays full-width to
|
||||
clear the 44pt tap-target floor. */}
|
||||
{activeTokens.length === 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm md:hidden"
|
||||
data-testid="settings-api-tokens-active-empty"
|
||||
>
|
||||
{t("settings.noActiveTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-mobile-list"
|
||||
>
|
||||
{activeTokens.map((tok) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{revokedTokens.length > 0 && (
|
||||
@@ -359,47 +506,89 @@ function ApiTokensCard() {
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
<>
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — stacked meta layout, no
|
||||
horizontal scroll. Revoked tokens are read-only so
|
||||
no action footer is needed. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-revoked-mobile-list"
|
||||
>
|
||||
{revokedTokens.map((tok) => (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,10 +44,6 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
oxygenSaturation: "measurements.typeOxygenSaturation",
|
||||
achievements: "achievements.title",
|
||||
// v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview tile
|
||||
// for the polished `<InsightsCardPreview>` (top severity-ordered AI
|
||||
// recommendations + ring confidence meter + "View all" CTA).
|
||||
insightsPreview: "dashboard.insightsPreview",
|
||||
// v1.4.25 W8d — VO2 max secondary-metric tile (opt-in).
|
||||
vo2Max: "dashboard.vo2Max",
|
||||
};
|
||||
@@ -245,7 +241,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
{isLoading || !layout ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
@@ -257,7 +253,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
of the two surfaces (per feedback_dashboard_top_tiles
|
||||
_selectable.md). */}
|
||||
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<span className="w-5" aria-hidden="true" />
|
||||
<span className="w-11" aria-hidden="true" />
|
||||
<span className="flex-1" />
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutTileColumn")}
|
||||
@@ -284,25 +280,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
className="size-11"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
className="size-11"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
@@ -346,7 +342,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -224,7 +224,7 @@ function DoctorReportCard() {
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
{busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
|
||||
{t("settings.sections.export.cards.doctorReport.action")}
|
||||
</Button>
|
||||
}
|
||||
@@ -358,7 +358,7 @@ function CsvCard({
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -445,7 +445,7 @@ function MedicationsCsvCard() {
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -534,7 +534,7 @@ function FullBackupCard() {
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<FileText className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
|
||||
@@ -385,11 +385,20 @@ function WithingsCard({
|
||||
</a>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.withingsCredentials")}
|
||||
</h3>
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from
|
||||
a 3-column row (client-id / secret / save) to a
|
||||
2-column row of inputs at `sm:`, with the Save button
|
||||
lifted out into its own row below. The previous
|
||||
"invisible Label" hack to align the button with the
|
||||
input baselines fell apart on Galaxy Fold; lifting
|
||||
the button into a dedicated `flex justify-end` row
|
||||
gives it consistent placement on every viewport and
|
||||
lets the input pair span the full width. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="w-clientid">
|
||||
{t("settings.withingsClientId")}
|
||||
@@ -404,6 +413,11 @@ function WithingsCard({
|
||||
: t("settings.withingsClientId")
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -420,27 +434,31 @@ function WithingsCard({
|
||||
: t("settings.withingsClientSecret")
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="invisible">{t("common.save")}</Label>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full"
|
||||
disabled={
|
||||
credsSaving || !clientId.trim() || !clientSecret.trim()
|
||||
}
|
||||
>
|
||||
{credsSaving ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.withingsSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={
|
||||
credsSaving || !clientId.trim() || !clientSecret.trim()
|
||||
}
|
||||
>
|
||||
{credsSaving ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.withingsSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
{credsMsg && (
|
||||
<p
|
||||
@@ -455,7 +473,13 @@ function WithingsCard({
|
||||
|
||||
{status?.connected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{/* v1.4.27 MB7 / CF-57 — the action row already wraps via
|
||||
`flex-wrap`, but on Pixel 5 the four AlertDialog +
|
||||
test-connection triggers each took a fractional slot
|
||||
that read jagged. Force each button to a `min-w-[10rem]`
|
||||
on `<sm` so they stack two per row at most and fill
|
||||
their column cleanly. Tablet+ keeps the inline row. */}
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -463,7 +487,7 @@ function WithingsCard({
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -473,7 +497,7 @@ function WithingsCard({
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={syncing}>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -703,6 +727,10 @@ function MoodLogCard({
|
||||
placeholder={t("settings.moodLogUrlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -718,6 +746,11 @@ function MoodLogCard({
|
||||
}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
@@ -726,7 +759,7 @@ function MoodLogCard({
|
||||
disabled={saving || (!url.trim() && !apiKey.trim())}
|
||||
size="sm"
|
||||
>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
@@ -792,7 +825,7 @@ function MoodLogCard({
|
||||
disabled={syncing}
|
||||
onClick={() => handleSync(false)}
|
||||
>
|
||||
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("settings.moodLogSync")}
|
||||
</Button>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function NotificationStatusCard() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -279,7 +279,7 @@ function ChannelRow({
|
||||
className="min-h-11"
|
||||
>
|
||||
{reEnablePending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -296,7 +296,7 @@ function ChannelRow({
|
||||
className="min-h-11"
|
||||
>
|
||||
{testPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Send className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -54,18 +54,23 @@ export function NotificationsSection() {
|
||||
|
||||
<NotificationStatusCard />
|
||||
|
||||
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each notification card's
|
||||
anchor target below the safe-area sticky page header when the
|
||||
user lands on `/settings/notifications#telegram` or similar.
|
||||
Without the offset the section title pins under the header on
|
||||
mobile and the user has to scroll back up to find it. */}
|
||||
{showTelegram && (
|
||||
<div id="telegram">
|
||||
<div id="telegram" className="scroll-mt-28">
|
||||
<TelegramCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showNtfy && (
|
||||
<div id="ntfy">
|
||||
<div id="ntfy" className="scroll-mt-28">
|
||||
<NtfyCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showWebPush && (
|
||||
<div id="web-push">
|
||||
<div id="web-push" className="scroll-mt-28">
|
||||
<WebPushCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface NtfySettings {
|
||||
@@ -195,7 +195,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Send className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
@@ -203,7 +203,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</Button>
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{save.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Password input with a built-in show/hide toggle. Extracted from the legacy
|
||||
* settings monolith so every section that needs a masked secret (Withings,
|
||||
* moodLog, ntfy, Telegram, AI provider keys, account password) can share the
|
||||
* same UX without duplicating the visibility-toggle plumbing.
|
||||
*/
|
||||
export function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} type={visible ? "text" : "password"} />
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
// WCAG 2.1.2 button-name — the icon-only toggle has no visible text
|
||||
// and screen readers fall back to the icon's filename otherwise.
|
||||
aria-label={
|
||||
visible ? t("common.hidePassword") : t("common.showPassword")
|
||||
}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
>
|
||||
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -214,8 +214,12 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main column — page renders its own h1 + subtitle */}
|
||||
<main className="min-w-0">{children}</main>
|
||||
{/* Main column — page renders its own h1 + subtitle. The
|
||||
`min-h-[calc(100dvh-12rem)]` reserve keeps the column tall
|
||||
enough that swapping a short loading state for a long
|
||||
section list (Thresholds, Sources) does not jump the page
|
||||
height under the sticky sidebar. */}
|
||||
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -314,10 +315,7 @@ export function SourcesSection() {
|
||||
</p>
|
||||
|
||||
{isLoading || !priority ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
<SourcesSkeletonList />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => {
|
||||
@@ -346,30 +344,37 @@ export function SourcesSection() {
|
||||
<span className="flex-1 text-sm">
|
||||
{t(SOURCE_LABEL_KEYS[source] ?? source)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveSource(metric, index, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("settings.sections.sources.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveSource(metric, index, 1)}
|
||||
disabled={
|
||||
index === list.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("settings.sections.sources.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{/* v1.4.27 R3d MB2 — stack up/down vertically on
|
||||
narrow viewports so each button keeps the
|
||||
44 px floor without crowding the row. From
|
||||
`sm:` up the two buttons sit side-by-side
|
||||
again to preserve the desktop layout. */}
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveSource(metric, index, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("settings.sections.sources.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveSource(metric, index, 1)}
|
||||
disabled={
|
||||
index === list.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("settings.sections.sources.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -504,32 +509,36 @@ export function SourcesSection() {
|
||||
<span className="flex-1 text-sm">
|
||||
{t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() =>
|
||||
moveDeviceType(null, index, -1)
|
||||
}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("settings.sections.sources.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveDeviceType(null, index, 1)}
|
||||
disabled={
|
||||
index === list.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("settings.sections.sources.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{/* v1.4.27 R3d MB2 — same stacked-on-mobile shape
|
||||
as the metric source rows above. */}
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() =>
|
||||
moveDeviceType(null, index, -1)
|
||||
}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("settings.sections.sources.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveDeviceType(null, index, 1)}
|
||||
disabled={
|
||||
index === list.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("settings.sections.sources.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -564,3 +573,33 @@ export function SourcesSection() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton placeholder rendered while `/api/auth/me/source-priority`
|
||||
* is in flight. Reserves one row per `SOURCE_PRIORITY_METRIC_KEYS`
|
||||
* entry at roughly the loaded height so the page does not jump when
|
||||
* the fetched ladder list swaps in. The pulsing animation honours
|
||||
* `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`.
|
||||
*/
|
||||
function SourcesSkeletonList() {
|
||||
return (
|
||||
<div
|
||||
className="space-y-3"
|
||||
data-testid="sources-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => (
|
||||
<div
|
||||
key={metric}
|
||||
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
|
||||
>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface TelegramSettings {
|
||||
@@ -148,6 +148,11 @@ export function TelegramCard({
|
||||
: "123456:ABC-DEF..."
|
||||
}
|
||||
maxLength={100}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -158,6 +163,11 @@ export function TelegramCard({
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
placeholder="123456789"
|
||||
maxLength={50}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +210,7 @@ export function TelegramCard({
|
||||
onClick={handleTest}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
@@ -208,7 +218,7 @@ export function TelegramCard({
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function TestConnectionButton({
|
||||
disabled={disabled || testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plug className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -3,15 +3,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
SlidersHorizontal,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -121,7 +117,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
|
||||
>
|
||||
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
|
||||
already provides the title + description for this route, so the
|
||||
@@ -147,10 +143,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
<ThresholdsSkeletonList />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{METRIC_ORDER.map((metric) => (
|
||||
@@ -170,6 +163,41 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton placeholder rendered while `/api/user/thresholds` is in
|
||||
* flight. Reserves one row per `METRIC_ORDER` entry at roughly the
|
||||
* loaded height so the page does not jump when the fetched list
|
||||
* swaps in. The pulsing animation honours `prefers-reduced-motion`
|
||||
* via Tailwind's `motion-reduce:animate-none`.
|
||||
*/
|
||||
function ThresholdsSkeletonList() {
|
||||
return (
|
||||
<div
|
||||
className="space-y-3"
|
||||
data-testid="thresholds-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{METRIC_ORDER.map((metric) => (
|
||||
<div
|
||||
key={metric}
|
||||
className="border-border space-y-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-5 w-9 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricRowProps {
|
||||
metric: ThresholdMetric;
|
||||
effective: EffectiveRange | undefined;
|
||||
@@ -254,6 +282,8 @@ function MetricRow({
|
||||
<Input
|
||||
id={`min-${metric}`}
|
||||
type="number"
|
||||
inputMode={metric === "ACTIVITY_STEPS" ? "numeric" : "decimal"}
|
||||
enterKeyHint="next"
|
||||
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
@@ -270,6 +300,8 @@ function MetricRow({
|
||||
<Input
|
||||
id={`max-${metric}`}
|
||||
type="number"
|
||||
inputMode={metric === "ACTIVITY_STEPS" ? "numeric" : "decimal"}
|
||||
enterKeyHint="done"
|
||||
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
|
||||
@@ -21,14 +21,17 @@ import { Compass } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import {
|
||||
detectBrowserTimezone,
|
||||
listSupportedTimezones,
|
||||
} from "@/lib/tz/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
|
||||
// retired; the shared `<NativeSelect>` primitive owns the visual
|
||||
// contract now. The `sm:max-w-sm` modifier still composes via the
|
||||
// `className` prop below so the picker keeps its narrow desktop width.
|
||||
|
||||
export interface TimezonePickerProps {
|
||||
/** The user's current stored timezone. */
|
||||
@@ -88,12 +91,12 @@ export function TimezonePicker({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("settings.timezone")}</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${NATIVE_SELECT_CLASS} sm:max-w-sm`}
|
||||
className="sm:max-w-sm"
|
||||
>
|
||||
{/* Preserve the stored value even if the runtime's IANA
|
||||
list changed across engines or tzdata rolls. */}
|
||||
@@ -105,7 +108,7 @@ export function TimezonePicker({
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -182,7 +182,7 @@ export function WebPushCard() {
|
||||
<div className="mt-4 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !isSupported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -202,7 +202,7 @@ export function WebPushCard() {
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.webPushUnsubscribe")}
|
||||
</Button>
|
||||
@@ -214,7 +214,7 @@ export function WebPushCard() {
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<BellRing className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user