Simpl-Resultat/src/components/settings/LogViewerCard.tsx
maximus 4f4ab87bea feat: feedback hub widget in Settings Logs card (#67)
Closes #67

Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:36:26 +00:00

152 lines
5.3 KiB
TypeScript

import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react";
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
import FeedbackDialog from "./FeedbackDialog";
import FeedbackConsentDialog from "./FeedbackConsentDialog";
type Filter = "all" | LogLevel;
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
export default function LogViewerCard() {
const { t } = useTranslation();
const [filter, setFilter] = useState<Filter>("all");
const [copied, setCopied] = useState(false);
const [consentOpen, setConsentOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const openFeedback = () => {
const accepted = localStorage.getItem(FEEDBACK_CONSENT_KEY) === "true";
if (accepted) {
setFeedbackOpen(true);
} else {
setConsentOpen(true);
}
};
const acceptConsent = () => {
localStorage.setItem(FEEDBACK_CONSENT_KEY, "true");
setConsentOpen(false);
setFeedbackOpen(true);
};
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [filtered.length]);
const handleCopy = async () => {
const text = filtered
.map((l) => {
const time = new Date(l.timestamp).toLocaleTimeString();
return `[${time}] [${l.level.toUpperCase()}] ${l.message}`;
})
.join("\n");
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const levelColor: Record<LogLevel, string> = {
info: "text-[var(--muted-foreground)]",
warn: "text-amber-500",
error: "text-[var(--negative)]",
};
const filters: { value: Filter; label: string }[] = [
{ value: "all", label: t("settings.logs.filterAll") },
{ value: "error", label: "Error" },
{ value: "warn", label: "Warn" },
{ value: "info", label: "Info" },
];
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<ScrollText size={18} />
{t("settings.logs.title")}
</h2>
<div className="flex items-center gap-2">
<button
onClick={openFeedback}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<MessageSquarePlus size={14} />
{t("feedback.button")}
</button>
<button
onClick={handleCopy}
disabled={filtered.length === 0}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? t("settings.logs.copied") : t("settings.logs.copy")}
</button>
<button
onClick={clearLogs}
disabled={logs.length === 0}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
<Trash2 size={14} />
{t("settings.logs.clear")}
</button>
</div>
</div>
<div className="flex gap-1">
{filters.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
filter === f.value
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{f.label}
</button>
))}
</div>
<div
ref={listRef}
className="h-64 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 font-mono text-xs space-y-0.5"
>
{filtered.length === 0 ? (
<p className="text-[var(--muted-foreground)] text-center py-8">
{t("settings.logs.empty")}
</p>
) : (
filtered.map((entry, i) => (
<div key={i} className="flex gap-2">
<span className="text-[var(--muted-foreground)] shrink-0">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className={`shrink-0 uppercase font-semibold w-12 ${levelColor[entry.level]}`}>
{entry.level}
</span>
<span className="text-[var(--foreground)] break-all">{entry.message}</span>
</div>
))
)}
</div>
{consentOpen && (
<FeedbackConsentDialog
onAccept={acceptConsent}
onCancel={() => setConsentOpen(false)}
/>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
</div>
);
}