mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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.
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
CheckCircle2,
|
|
ExternalLink,
|
|
GitPullRequest,
|
|
Inbox,
|
|
Loader2,
|
|
MessageSquare,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import {
|
|
FEEDBACK_STATUS_TABS,
|
|
type FeedbackCategoryType,
|
|
type FeedbackItem,
|
|
type FeedbackListResponse,
|
|
type FeedbackStatusType,
|
|
getApiErrorMessage,
|
|
useSystemStatus,
|
|
} from "./_shared";
|
|
|
|
export function FeedbackInboxSection() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const { data: status } = useSystemStatus();
|
|
const githubConfigured = Boolean(status?.integrations?.bugReport?.configured);
|
|
|
|
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
|
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: queryKeys.adminFeedback(activeStatus),
|
|
queryFn: async () => {
|
|
const res = await fetch(
|
|
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
|
);
|
|
if (!res.ok) throw new Error("Failed");
|
|
return (await res.json()).data as FeedbackListResponse;
|
|
},
|
|
});
|
|
|
|
const counts = data?.meta?.countsByStatus ?? {};
|
|
|
|
function refresh() {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() });
|
|
}
|
|
|
|
return (
|
|
<div className="bg-card border-border rounded-xl border p-6">
|
|
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
|
|
`/admin/feedback`, so the page header already provides the
|
|
title + description — the card-level repetition was just
|
|
visual noise. The icon stays as a low-key card affordance. */}
|
|
<Inbox className="text-primary h-5 w-5" aria-hidden="true" />
|
|
|
|
<Tabs
|
|
value={activeStatus}
|
|
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
|
className="mt-4"
|
|
>
|
|
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
|
|
overflow-x-auto strip so the 4 status pills + their count
|
|
badges scroll horizontally on Pixel 5 / Galaxy Fold
|
|
instead of squashing the badge text below the legibility
|
|
floor. Hide the scrollbar visually but keep the gesture
|
|
working (matches the Insights tab-strip pattern). */}
|
|
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
|
|
<TabsList>
|
|
{FEEDBACK_STATUS_TABS.map((s) => (
|
|
<TabsTrigger key={s} value={s}>
|
|
<span>
|
|
{t(
|
|
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
|
)}
|
|
</span>
|
|
<Badge variant="secondary" className="ml-1.5 text-xs">
|
|
{counts[s] ?? 0}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{FEEDBACK_STATUS_TABS.map((s) => (
|
|
<TabsContent key={s} value={s} className="mt-4">
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
<span className="text-muted-foreground text-sm">
|
|
{t("admin.feedback.loading")}
|
|
</span>
|
|
</div>
|
|
) : !data?.items?.length ? (
|
|
// v1.4.15 phase-C5: per-tab empty state. Compact size so
|
|
// it sits naturally under the tab strip without dwarfing
|
|
// a typical 1-2-row inbox once entries arrive.
|
|
<EmptyState
|
|
size="compact"
|
|
icon={<Inbox className="size-5" />}
|
|
title={t("admin.feedback.emptyTitle")}
|
|
description={t("admin.feedback.emptyDescription")}
|
|
/>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-muted-foreground border-b text-xs">
|
|
<th className="px-3 py-2 text-left font-medium">
|
|
{t("admin.feedback.createdAt")}
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-medium">
|
|
{t("admin.feedback.category")}
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-medium">
|
|
{t("admin.feedback.subject")}
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-medium">
|
|
{t("admin.feedback.user")}
|
|
</th>
|
|
<th className="px-3 py-2 text-right font-medium">
|
|
{t("admin.feedback.actions")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-border divide-y">
|
|
{data.items.map((item, i) => (
|
|
<tr
|
|
key={item.id}
|
|
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
|
onClick={() => setSelected(item)}
|
|
>
|
|
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
|
{formatDateTime(item.createdAt)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<FeedbackCategoryBadge category={item.category} />
|
|
</td>
|
|
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
|
{item.subject}
|
|
</td>
|
|
<td className="text-muted-foreground px-3 py-2 text-xs">
|
|
{item.user?.username ?? t("admin.feedback.anonymous")}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelected(item);
|
|
}}
|
|
>
|
|
{t("admin.feedback.details")}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
|
|
{selected && (
|
|
<FeedbackDetailDialog
|
|
item={selected}
|
|
open={!!selected}
|
|
onOpenChange={(open) => !open && setSelected(null)}
|
|
githubConfigured={githubConfigured}
|
|
onMutated={refresh}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FeedbackCategoryBadge({
|
|
category,
|
|
}: {
|
|
category: FeedbackCategoryType;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const map: Record<
|
|
FeedbackCategoryType,
|
|
{ label: string; className: string }
|
|
> = {
|
|
BUG: {
|
|
label: t("admin.feedback.categoryBug"),
|
|
// Bug = something that's broken → semantic `destructive` token instead
|
|
// of raw `dracula-red`. Visually identical in dark mode (both resolve
|
|
// to #ff5555); light mode now picks up the AA-contrast `#cb3a2a`.
|
|
className: "bg-destructive/15 text-destructive border-destructive/30",
|
|
},
|
|
FEATURE_REQUEST: {
|
|
label: t("admin.feedback.categoryFeature"),
|
|
className:
|
|
"bg-dracula-purple/15 text-dracula-purple border-dracula-purple/30",
|
|
},
|
|
QUESTION: {
|
|
label: t("admin.feedback.categoryQuestion"),
|
|
className: "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/30",
|
|
},
|
|
OTHER: {
|
|
label: t("admin.feedback.categoryOther"),
|
|
className: "bg-muted text-muted-foreground border-border",
|
|
},
|
|
};
|
|
const cfg = map[category];
|
|
return (
|
|
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
|
);
|
|
}
|
|
|
|
function FeedbackDetailDialog({
|
|
item,
|
|
open,
|
|
onOpenChange,
|
|
githubConfigured,
|
|
onMutated,
|
|
}: {
|
|
item: FeedbackItem;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
githubConfigured: boolean;
|
|
onMutated: () => void;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const [note, setNote] = useState(item.adminNote ?? "");
|
|
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
|
|
|
const update = useMutation({
|
|
mutationFn: async (payload: {
|
|
status?: FeedbackStatusType;
|
|
adminNote?: string | null;
|
|
}) => {
|
|
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
|
},
|
|
onSuccess: (_, vars) => {
|
|
onMutated();
|
|
if (vars.adminNote !== undefined) {
|
|
toast.success(t("admin.feedback.noteSaved"));
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
|
);
|
|
},
|
|
});
|
|
|
|
const archive = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
|
},
|
|
onSuccess: () => {
|
|
onMutated();
|
|
onOpenChange(false);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
|
);
|
|
},
|
|
});
|
|
|
|
const publish = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
|
return (await res.json()).data as { issueUrl: string };
|
|
},
|
|
onSuccess: (data) => {
|
|
setIssueUrl(data.issueUrl);
|
|
toast.success(t("admin.feedback.publishSuccess"));
|
|
onMutated();
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
|
);
|
|
},
|
|
});
|
|
|
|
const meta = item.metadata ?? {};
|
|
const url = typeof meta.url === "string" ? meta.url : null;
|
|
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
|
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
|
const appVersion =
|
|
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
|
|
|
return (
|
|
<ResponsiveSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5" />
|
|
{item.subject}
|
|
</span>
|
|
}
|
|
description={
|
|
<span className="flex flex-wrap items-center gap-2 text-xs">
|
|
<FeedbackCategoryBadge category={item.category} />
|
|
<span>
|
|
{t("admin.feedback.submittedBy")}:{" "}
|
|
{item.user?.username ?? t("admin.feedback.anonymous")}
|
|
</span>
|
|
<span>·</span>
|
|
<span>{formatDateTime(item.createdAt)}</span>
|
|
{issueUrl && (
|
|
<a
|
|
href={issueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary inline-flex items-center gap-1"
|
|
>
|
|
<GitPullRequest className="h-3 w-3" />
|
|
{t("admin.feedback.viewIssue")}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
)}
|
|
</span>
|
|
}
|
|
className="sm:max-w-2xl"
|
|
footer={
|
|
<div className="flex w-full flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
|
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
|
>
|
|
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
|
{t("admin.feedback.actionAcknowledge")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={update.isPending || item.status === "RESOLVED"}
|
|
onClick={() => update.mutate({ status: "RESOLVED" })}
|
|
>
|
|
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
|
{t("admin.feedback.actionResolve")}
|
|
</Button>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={archive.isPending || item.status === "ARCHIVED"}
|
|
>
|
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
{t("admin.feedback.actionArchive")}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("admin.feedback.archiveConfirm")}
|
|
</AlertDialogTitle>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => archive.mutate()}
|
|
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>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
{githubConfigured && !issueUrl && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={publish.isPending}
|
|
onClick={() => publish.mutate()}
|
|
className="ml-auto"
|
|
>
|
|
{publish.isPending ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
{t("admin.feedback.actionPublishGithub")}
|
|
</Button>
|
|
)}
|
|
{issueUrl && (
|
|
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
|
|
{t("admin.feedback.publishedToGithub")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-4 text-sm">
|
|
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
|
{item.description}
|
|
</div>
|
|
|
|
{(url || locale || userAgent || appVersion) && (
|
|
<div>
|
|
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
|
sequential beneath the ResponsiveSheet's h2 title. */}
|
|
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
|
{t("admin.feedback.metadataHeading")}
|
|
</h3>
|
|
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
|
{url && (
|
|
<>
|
|
<dt className="text-muted-foreground">
|
|
{t("admin.feedback.metaUrl")}
|
|
</dt>
|
|
<dd className="font-mono break-all">{url}</dd>
|
|
</>
|
|
)}
|
|
{locale && (
|
|
<>
|
|
<dt className="text-muted-foreground">
|
|
{t("admin.feedback.metaLocale")}
|
|
</dt>
|
|
<dd className="font-mono">{locale}</dd>
|
|
</>
|
|
)}
|
|
{appVersion && (
|
|
<>
|
|
<dt className="text-muted-foreground">
|
|
{t("admin.feedback.metaAppVersion")}
|
|
</dt>
|
|
<dd className="font-mono">{appVersion}</dd>
|
|
</>
|
|
)}
|
|
{userAgent && (
|
|
<>
|
|
<dt className="text-muted-foreground">
|
|
{t("admin.feedback.metaUserAgent")}
|
|
</dt>
|
|
<dd className="font-mono break-all">{userAgent}</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
|
|
{item.screenshotBase64 && (
|
|
<div>
|
|
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
|
{t("admin.feedback.screenshotHeading")}
|
|
</h3>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={item.screenshotBase64}
|
|
alt="Screenshot"
|
|
className="border-border max-h-72 rounded-md border"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="admin-feedback-note" className="text-xs">
|
|
{t("admin.feedback.adminNote")}
|
|
</Label>
|
|
<Textarea
|
|
id="admin-feedback-note"
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
rows={3}
|
|
maxLength={5000}
|
|
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={update.isPending || note === (item.adminNote ?? "")}
|
|
onClick={() => update.mutate({ adminNote: note || null })}
|
|
>
|
|
{update.isPending && (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("admin.feedback.saveNote")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ResponsiveSheet>
|
|
);
|
|
}
|