mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(ui): unclip the score-ring glow, reachable insight pills, consistent destructive actions
The score ring's bloom was clipped by the SVG's own viewport — the drop-shadow sits on a child of the viewBox-filling arc — so the glow never extended past the circle; the SVG now overflows visibly on every host. The insight pill strip gains chevron buttons and wheel scrolling so an overflowing row is reachable with a mouse. Safari favourites rendered the transparent icon on a white plate; a flattened apple-touch-icon and a maskable manifest icon fix both. The time-of-day mood chart adopts the damped fill the weekday chart already uses, eighteen delete confirmations move onto the destructive button variant (one of them had the wrong text colour), five confirmation dialogs gain pending states, the sources dialog stacks its primary action on top on mobile, and the token form aligns input and button heights.
This commit is contained in:
1 parent
298e4c34b4
commit
6f45d72a84
28 files changed
+221
-49
No files matched your search
+3
-1
@@ -834,7 +834,7 @@
|
||||
"title": "Medikament beschreiben",
|
||||
"description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.",
|
||||
"placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag",
|
||||
"submit": "Übernehmen",
|
||||
"submit": "Extrahieren",
|
||||
"submitBusy": "Wird verarbeitet …",
|
||||
"cancel": "Abbrechen",
|
||||
"examples": {
|
||||
@@ -2099,6 +2099,8 @@
|
||||
"navSteps": "Schritte",
|
||||
"navActiveEnergy": "Aktive Energie",
|
||||
"navAriaLabel": "Zu Abschnitt springen",
|
||||
"pillScrollLeft": "Tabs nach links scrollen",
|
||||
"pillScrollRight": "Tabs nach rechts scrollen",
|
||||
"tabStrip": {
|
||||
"vitalsParent": {
|
||||
"label": "Vitalwerte",
|
||||
|
||||
@@ -2099,6 +2099,8 @@
|
||||
"navSteps": "Steps",
|
||||
"navActiveEnergy": "Active energy",
|
||||
"navAriaLabel": "Skip to section",
|
||||
"pillScrollLeft": "Scroll tabs left",
|
||||
"pillScrollRight": "Scroll tabs right",
|
||||
"tabStrip": {
|
||||
"vitalsParent": {
|
||||
"label": "Vitals",
|
||||
|
||||
@@ -2099,6 +2099,8 @@
|
||||
"navSteps": "Pasos",
|
||||
"navActiveEnergy": "Energía activa",
|
||||
"navAriaLabel": "Saltar a sección",
|
||||
"pillScrollLeft": "Desplazar las pestañas a la izquierda",
|
||||
"pillScrollRight": "Desplazar las pestañas a la derecha",
|
||||
"tabStrip": {
|
||||
"vitalsParent": {
|
||||
"label": "Vitales",
|
||||
|
||||
@@ -2099,6 +2099,8 @@
|
||||
"navSteps": "Pas",
|
||||
"navActiveEnergy": "Énergie active",
|
||||
"navAriaLabel": "Aller à la section",
|
||||
"pillScrollLeft": "Faire défiler les onglets vers la gauche",
|
||||
"pillScrollRight": "Faire défiler les onglets vers la droite",
|
||||
"tabStrip": {
|
||||
"vitalsParent": {
|
||||
"label": "Constantes",
|
||||
|
||||
@@ -2099,6 +2099,8 @@
|
||||
"navSteps": "Passi",
|
||||
"navActiveEnergy": "Energia attiva",
|
||||
"navAriaLabel": "Vai alla sezione",
|
||||
"pillScrollLeft": "Scorri le schede a sinistra",
|
||||
"pillScrollRight": "Scorri le schede a destra",
|
||||
"tabStrip": {
|
||||
"vitalsParent": {
|
||||
"label": "Vitali",
|
||||
|
||||
@@ -2099,6 +2099,8 @@
|
||||
"navSteps": "Kroki",
|
||||
"navActiveEnergy": "Energia czynna",
|
||||
"navAriaLabel": "Przejdź do sekcji",
|
||||
"pillScrollLeft": "Przewiń karty w lewo",
|
||||
"pillScrollRight": "Przewiń karty w prawo",
|
||||
"tabStrip": {
|
||||
"vitalsParent": {
|
||||
"label": "Witalne",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+10
-2
@@ -13,12 +13,20 @@
|
||||
{
|
||||
"src": "/logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -39,7 +39,10 @@ export const metadata: Metadata = {
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/logo-192.png",
|
||||
// Pre-flattened 180×180 on the app background — Safari ignores PNG
|
||||
// alpha in favourites/home-screen tiles and would render the
|
||||
// transparent logo on a white slab otherwise.
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
|
||||
@@ -127,7 +127,7 @@ function RestoreRowDialog({
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={!matched || pending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!matched) return;
|
||||
setOpen(false);
|
||||
|
||||
@@ -104,9 +104,14 @@ export function DangerZoneSection() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
disabled={wipeAllData.isPending}
|
||||
aria-busy={wipeAllData.isPending || undefined}
|
||||
onClick={() => wipeAllData.mutate()}
|
||||
>
|
||||
{wipeAllData.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -398,8 +398,13 @@ function FeedbackDetailDialog({
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending}
|
||||
aria-busy={archive.isPending || undefined}
|
||||
>
|
||||
{archive.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -560,7 +560,9 @@ export function UserManagementSection() {
|
||||
forceLogout.mutate({ id: logoutTarget.id });
|
||||
}
|
||||
}}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
disabled={forceLogout.isPending}
|
||||
aria-busy={forceLogout.isPending || undefined}
|
||||
>
|
||||
{forceLogout.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -64,7 +64,7 @@ export function DeleteButton({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel ?? t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel ?? t("common.delete")}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function SelectionActionBar({
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={onConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
|
||||
@@ -122,8 +122,7 @@ export function ScoreRing({
|
||||
const shouldAnimate = animate && hasScore && !reduced;
|
||||
const dash = (clamped / 100) * C;
|
||||
|
||||
const hasBaseline =
|
||||
baseline != null && Number.isFinite(baseline) && hasScore;
|
||||
const hasBaseline = baseline != null && Number.isFinite(baseline) && hasScore;
|
||||
const baselineDash = hasBaseline ? (clampScore(baseline) / 100) * C : 0;
|
||||
|
||||
// Arc starts empty (offset C) and sweeps to its fill on the next frame after
|
||||
@@ -170,9 +169,14 @@ export function ScoreRing({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* `overflow-visible`: the arc's drop-shadow bloom (`.wellness-ring-arc`)
|
||||
paints outside the viewBox — the arc fills the 0 0 100 100 box, so the
|
||||
default `overflow: hidden` on SVG clipped the glow flat at the circle's
|
||||
edge. The -90° rotation is unaffected (the bloom is radially symmetric)
|
||||
and the parent div never clips, so the glow now feathers out fully. */}
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
||||
className="h-full w-full -rotate-90"
|
||||
className="h-full w-full -rotate-90 overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useMemo, useRef } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronDown, Loader2, RefreshCw, Settings2 } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -13,6 +20,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
SUB_PAGE_GROUP,
|
||||
@@ -226,7 +234,10 @@ export const SUB_PAGE_TABS: Record<
|
||||
metric: "WALKING_SPEED",
|
||||
},
|
||||
// v1.10.0 — cardio fitness (VO2 max) + Apple-Health Mobility additions.
|
||||
"cardio-fitness": { labelKey: "insights.navCardioFitness", metric: "VO2_MAX" },
|
||||
"cardio-fitness": {
|
||||
labelKey: "insights.navCardioFitness",
|
||||
metric: "VO2_MAX",
|
||||
},
|
||||
falls: { labelKey: "insights.navFalls", metric: "FALL_COUNT" },
|
||||
"six-minute-walk": {
|
||||
labelKey: "insights.navSixMinuteWalk",
|
||||
@@ -498,6 +509,63 @@ function InsightsTabStripImpl({
|
||||
const regenerateLabel = t("insights.regenerateAnalysis");
|
||||
const customizeLabel = t("insights.customize");
|
||||
|
||||
// Overflow affordance for the pill row. The row hides its scrollbar
|
||||
// (deliberately — see the container classes below), which left desktop
|
||||
// users with no signal and no obvious gesture once the pills overflow:
|
||||
// mouse wheels scroll vertically and there is nothing to grab. Three
|
||||
// complements close that gap:
|
||||
// • chevron buttons (sm+ only — touch keeps native pan) that page the
|
||||
// row ±200 px; they render whenever the row overflows and disable
|
||||
// per-direction so focus never lands on a vanished control;
|
||||
// • a native non-passive wheel listener that turns a dominant vertical
|
||||
// wheel delta into horizontal scroll (React's synthetic onWheel is
|
||||
// passive at the root, so `preventDefault()` must bind natively);
|
||||
// • the existing `<sm` right-edge fade stays as the mobile signal.
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollAffordance = useCallback(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft < maxScroll - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-measure on mount, when the pill set changes, and on any resize of
|
||||
// the scroll container (sidebar collapse, orientation change).
|
||||
updateScrollAffordance();
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(updateScrollAffordance);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [updateScrollAffordance, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (el.scrollWidth <= el.clientWidth) return;
|
||||
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
||||
el.scrollLeft += event.deltaY;
|
||||
event.preventDefault();
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
const scrollPills = useCallback((direction: -1 | 1) => {
|
||||
scrollerRef.current?.scrollBy({
|
||||
left: direction * 200,
|
||||
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pillsOverflow = canScrollLeft || canScrollRight;
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-slot="insights-tab-strip"
|
||||
@@ -523,7 +591,28 @@ function InsightsTabStripImpl({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
|
||||
{pillsOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPills(-1)}
|
||||
disabled={!canScrollLeft}
|
||||
aria-label={t("insights.pillScrollLeft")}
|
||||
data-slot="insights-tab-strip-scroll-left"
|
||||
className={cn(
|
||||
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={updateScrollAffordance}
|
||||
className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
if (tab.kind === "link") {
|
||||
// The overview pill matches the mother page exactly; the
|
||||
@@ -631,6 +720,23 @@ function InsightsTabStripImpl({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pillsOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPills(1)}
|
||||
disabled={!canScrollRight}
|
||||
aria-label={t("insights.pillScrollRight")}
|
||||
data-slot="insights-tab-strip-scroll-right"
|
||||
className={cn(
|
||||
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient
|
||||
absolute-positions over the rightmost ~24 px of the strip
|
||||
so the last visible pill softly fades into the background
|
||||
|
||||
@@ -25,11 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* a muted zero-height bar so the four-column rhythm stays intact.
|
||||
*/
|
||||
|
||||
export type MoodTimeOfDayBucket =
|
||||
| "morning"
|
||||
| "afternoon"
|
||||
| "evening"
|
||||
| "night";
|
||||
export type MoodTimeOfDayBucket = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
export interface MoodTimeOfDayRow {
|
||||
bucket: MoodTimeOfDayBucket;
|
||||
@@ -88,23 +84,28 @@ export function MoodTimeOfDayChart({
|
||||
data={data}
|
||||
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
|
||||
>
|
||||
{/* Muted chart language — same treatment as the weekday/distribution
|
||||
siblings: `--muted-foreground` axis ticks, a quiet comment-grey
|
||||
target guide, and 0.55 bar fill so the level hues inform without
|
||||
shouting. */}
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[1, 5]}
|
||||
ticks={[1, 2, 3, 4, 5]}
|
||||
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
width={24}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={3.5}
|
||||
stroke="var(--dracula-green)"
|
||||
stroke="var(--dracula-comment)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
|
||||
@@ -129,7 +130,7 @@ export function MoodTimeOfDayChart({
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]} fillOpacity={0.55}>
|
||||
{data.map((row) => (
|
||||
<Cell
|
||||
key={row.bucket}
|
||||
|
||||
@@ -1443,7 +1443,7 @@ export function MeasurementList({
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={deleteEditingMeasurement}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -498,7 +498,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={resetForm}
|
||||
>
|
||||
{t("mood.formResetConfirmAction")}
|
||||
|
||||
@@ -1018,7 +1018,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={deleteEditingEntry}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -940,9 +940,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -1022,9 +1027,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -327,7 +327,7 @@ function DataResetCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAllData();
|
||||
@@ -471,7 +471,7 @@ function AccountDeleteCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
|
||||
@@ -134,13 +134,13 @@ function ApiEndpointsCard() {
|
||||
<code className="font-mono break-all">{endpoint.path}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{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">
|
||||
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</p>
|
||||
<code className="block font-mono break-all">
|
||||
@@ -263,7 +263,10 @@ function ApiTokensCard() {
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10)
|
||||
so the row reads as one aligned control group instead of a tall
|
||||
input next to a shorter `sm` button. */}
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
@@ -274,10 +277,12 @@ function ApiTokensCard() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11 sm:h-10"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
|
||||
{creating && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -416,7 +421,7 @@ function ApiTokensCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
@@ -515,7 +520,7 @@ function ApiTokensCard() {
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
|
||||
@@ -197,8 +197,13 @@ export function CoachMemorySection({
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -208,7 +213,11 @@ export function CoachMemorySection({
|
||||
</div>
|
||||
|
||||
{query.isError && (
|
||||
<p role="status" aria-live="polite" className="text-destructive text-xs">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-destructive text-xs"
|
||||
>
|
||||
{t("settings.ai.coachMemory.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -622,7 +622,7 @@ function WithingsCard({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.withingsDisconnect")}
|
||||
@@ -1035,7 +1035,7 @@ function WhoopCard({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.whoopDisconnect")}
|
||||
@@ -1481,7 +1481,7 @@ function FitbitCard({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.fitbitDisconnect")}
|
||||
|
||||
@@ -387,7 +387,8 @@ export function SourcesSection() {
|
||||
className="h-11 w-11"
|
||||
onClick={() => moveSource(metric, index, 1)}
|
||||
disabled={
|
||||
index === list.length - 1 || saveMutation.isPending
|
||||
index === list.length - 1 ||
|
||||
saveMutation.isPending
|
||||
}
|
||||
aria-label={t("settings.sections.sources.moveDown")}
|
||||
>
|
||||
@@ -536,9 +537,7 @@ export function SourcesSection() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() =>
|
||||
moveDeviceType(null, index, -1)
|
||||
}
|
||||
onClick={() => moveDeviceType(null, index, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("settings.sections.sources.moveUp")}
|
||||
>
|
||||
@@ -566,8 +565,11 @@ export function SourcesSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary
|
||||
action stacks on top on mobile, row order stays Cancel → Save
|
||||
on `sm+`. */}
|
||||
{dirty && priority && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
Reference in new issue
Block a user