mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip. - Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from treatmentClass; injection-site picker surfaces for any injection. Migration 0088 backfills ORAL via a constant column default. - One-time injection (oneShot + deliveryForm = INJECTION) editor path. - Profile-photo upload card on Settings → Account. - Today-tile read-flip: /api/medications/intake?scope=today and /api/dashboard/summary gate through the canonical recurrence engine, matching the reminder worker for bi-weekly, rolling, RRULE, and one-time cadences. - Measurement note cap 25 → 200; dashboard range colors on semantic tokens; admin sidebar icons + shared-helper consolidation; API-tokens multi-language snippets restored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d01d1d1dc9
commit
8ee7aaac44
53 files changed
+1049
-1017
No files matched your search
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Compass,
|
||||
ImageUp,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Save,
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -376,6 +382,9 @@ export function AccountSection() {
|
||||
</header>
|
||||
|
||||
{/* Profile card */}
|
||||
{/* Profile photo card */}
|
||||
<AvatarSection />
|
||||
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<User className="text-primary h-5 w-5" />
|
||||
@@ -955,3 +964,167 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accepted upload types + byte cap mirror the server contract in
|
||||
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
|
||||
// bad file before the round-trip. The server still re-validates by
|
||||
// magic-byte sniff — this is a UX shortcut, not the security boundary.
|
||||
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function getAvatarInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function AvatarSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const res = await fetch("/api/user/avatar", { method: "POST", body });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 415) throw new Error(t("settings.avatar.invalidType"));
|
||||
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg(t("settings.avatar.uploaded"));
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/user/avatar", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg(t("settings.avatar.removed"));
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset so re-selecting the same file still fires `onChange`.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
|
||||
setMsgType("error");
|
||||
setMsg(t("settings.avatar.invalidType"));
|
||||
return;
|
||||
}
|
||||
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
|
||||
setMsgType("error");
|
||||
setMsg(t("settings.avatar.tooLarge"));
|
||||
return;
|
||||
}
|
||||
upload.mutate(file);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
const avatarUrl = user.avatarUrl ?? null;
|
||||
const busy = upload.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ImageUp className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.avatar.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{t("settings.avatar.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<ImageUp className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{avatarUrl
|
||||
? t("settings.avatar.replace")
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.avatar.hint")}
|
||||
</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${
|
||||
msgType === "success" ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
@@ -1796,12 +1797,20 @@ function RuntimeActionsRow({
|
||||
|
||||
async function togglePrivacy() {
|
||||
const next = privacyMode === "raw" ? "aggregated" : "raw";
|
||||
await fetch("/api/insights/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ privacyMode: next }),
|
||||
});
|
||||
onPrivacyChanged();
|
||||
// Privacy mode is a sensitive control: only reflect the new mode
|
||||
// once the server confirms it. A swallowed 4xx/5xx would leave the
|
||||
// UI showing a setting the server never accepted.
|
||||
try {
|
||||
const res = await fetch("/api/insights/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ privacyMode: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("request failed");
|
||||
onPrivacyChanged();
|
||||
} catch {
|
||||
toast.error(t("settings.savingError"));
|
||||
}
|
||||
}
|
||||
|
||||
const lastInsightLine = lastInsightAt
|
||||
|
||||
Reference in new issue
Block a user