feat: add localization for analytics widget and analytics modal
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 25s

This commit is contained in:
Chneemann 2026-08-06 12:06:44 +02:00
parent ea2defe0f0
commit ad4d4b4387
No known key found for this signature in database
4 changed files with 92 additions and 32 deletions

View file

@ -2,6 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
/** /**
* Analytics metrics payload returned by the custom backend API * Analytics metrics payload returned by the custom backend API
@ -24,20 +25,6 @@ interface AnalyticsModalProps {
stats: AnalyticsData; stats: AnalyticsData;
} }
/**
* Formats unix timestamps into relative time strings (e.g., "5s ago", "2m ago")
*/
function getTimeAgo(timestamp?: number): string {
if (!timestamp) return "just now";
const seconds = Math.floor((Date.now() - timestamp * 1000) / 1000);
if (seconds < 10) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
/** /**
* Terminal-style modal inspector for viewing live traffic and system health metric * Terminal-style modal inspector for viewing live traffic and system health metric
*/ */
@ -46,6 +33,22 @@ export default function AnalyticsModal({
onClose, onClose,
stats, stats,
}: AnalyticsModalProps) { }: AnalyticsModalProps) {
const tAnalyticsModal = useTranslations("AnalyticsModal");
/**
* Formats unix timestamps into relative time strings based on locale
*/
const getTimeAgo = (timestamp?: number): string => {
if (!timestamp) return tAnalyticsModal("timeJustNow");
const seconds = Math.floor((Date.now() - timestamp * 1000) / 1000);
if (seconds < 10) return tAnalyticsModal("timeJustNow");
if (seconds < 60) return tAnalyticsModal("timeSecondsAgo", { seconds });
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return tAnalyticsModal("timeMinutesAgo", { minutes });
const hours = Math.floor(minutes / 60);
return tAnalyticsModal("timeHoursAgo", { hours });
};
// Global hotkeys to dismiss the modal (ESC) // Global hotkeys to dismiss the modal (ESC)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@ -86,16 +89,16 @@ export default function AnalyticsModal({
<button <button
onClick={onClose} onClick={onClose}
className="w-2.5 h-2.5 rounded-full bg-rose-500/80 hover:bg-rose-500 transition-colors cursor-pointer" className="w-2.5 h-2.5 rounded-full bg-rose-500/80 hover:bg-rose-500 transition-colors cursor-pointer"
title="Close window" title={tAnalyticsModal("tooltipClose")}
/> />
<button <button
onClick={onClose} onClick={onClose}
className="w-2.5 h-2.5 rounded-full bg-amber-500/80 hover:bg-amber-500 transition-colors cursor-pointer" className="w-2.5 h-2.5 rounded-full bg-amber-500/80 hover:bg-amber-500 transition-colors cursor-pointer"
title="Minimize window" title={tAnalyticsModal("tooltipMinimize")}
/> />
<span <span
className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 hover:bg-emerald-500 transition-colors cursor-default" className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 hover:bg-emerald-500 transition-colors cursor-default"
title="Active Process" title={tAnalyticsModal("tooltipActive")}
/> />
</div> </div>
@ -112,7 +115,7 @@ export default function AnalyticsModal({
<button <button
onClick={onClose} onClick={onClose}
className="text-slate-500 hover:text-rose-400 transition-colors font-mono text-[11px] px-1.5 py-0.5 rounded hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 cursor-pointer" className="text-slate-500 hover:text-rose-400 transition-colors font-mono text-[11px] px-1.5 py-0.5 rounded hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 cursor-pointer"
title="Close modal (Esc)" title={tAnalyticsModal("tooltipEsc")}
> >
[esc ] [esc ]
</button> </button>
@ -126,10 +129,10 @@ export default function AnalyticsModal({
</div> </div>
<div className="text-[10px] text-slate-500 mt-1 flex items-center gap-2"> <div className="text-[10px] text-slate-500 mt-1 flex items-center gap-2">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span>STATUS: ONLINE</span> <span>{tAnalyticsModal("statusOnline")}</span>
<span className="text-slate-700"></span> <span className="text-slate-700"></span>
<span className="text-slate-500"> <span className="text-slate-500">
LATENCY:{" "} {tAnalyticsModal("latency")}:{" "}
<span className="text-slate-300"> <span className="text-slate-300">
{stats.latencyMs ?? "<1"}ms {stats.latencyMs ?? "<1"}ms
</span> </span>
@ -141,7 +144,7 @@ export default function AnalyticsModal({
<div className="grid grid-cols-2 gap-3 mb-4 relative z-10"> <div className="grid grid-cols-2 gap-3 mb-4 relative z-10">
<div className="p-3.5 rounded-xl bg-slate-950/80 border border-slate-800/80 hover:border-slate-700/80 transition-colors"> <div className="p-3.5 rounded-xl bg-slate-950/80 border border-slate-800/80 hover:border-slate-700/80 transition-colors">
<div className="flex justify-between items-center text-[10px] text-slate-500 mb-1"> <div className="flex justify-between items-center text-[10px] text-slate-500 mb-1">
<span>views_today</span> <span>{tAnalyticsModal("metricViewsToday")}</span>
<span className="text-blue-400/80">#1</span> <span className="text-blue-400/80">#1</span>
</div> </div>
<div className="text-2xl font-bold text-blue-400 tracking-tight"> <div className="text-2xl font-bold text-blue-400 tracking-tight">
@ -151,7 +154,7 @@ export default function AnalyticsModal({
<div className="p-3.5 rounded-xl bg-slate-950/80 border border-slate-800/80 hover:border-slate-700/80 transition-colors"> <div className="p-3.5 rounded-xl bg-slate-950/80 border border-slate-800/80 hover:border-slate-700/80 transition-colors">
<div className="flex justify-between items-center text-[10px] text-slate-500 mb-1"> <div className="flex justify-between items-center text-[10px] text-slate-500 mb-1">
<span>unique_visitors</span> <span>{tAnalyticsModal("metricUniqueVisitors")}</span>
<span className="text-emerald-400/80">#2</span> <span className="text-emerald-400/80">#2</span>
</div> </div>
<div className="text-2xl font-bold text-emerald-400 tracking-tight"> <div className="text-2xl font-bold text-emerald-400 tracking-tight">
@ -163,7 +166,9 @@ export default function AnalyticsModal({
{/* Total Hits & Device Breakdown */} {/* Total Hits & Device Breakdown */}
<div className="p-3.5 rounded-xl bg-slate-950/80 border border-slate-800/80 space-y-3 relative z-10"> <div className="p-3.5 rounded-xl bg-slate-950/80 border border-slate-800/80 space-y-3 relative z-10">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-slate-400">total_hits:</span> <span className="text-slate-400">
{tAnalyticsModal("metricTotalHits")}:
</span>
<span className="font-bold text-slate-100 bg-slate-900 px-2 py-0.5 rounded border border-slate-800"> <span className="font-bold text-slate-100 bg-slate-900 px-2 py-0.5 rounded border border-slate-800">
{stats.totalViews} {stats.totalViews}
</span> </span>
@ -172,7 +177,9 @@ export default function AnalyticsModal({
{/* Device ratio progress bar */} {/* Device ratio progress bar */}
<div className="pt-2 border-t border-slate-800/80 space-y-1.5"> <div className="pt-2 border-t border-slate-800/80 space-y-1.5">
<div className="flex justify-between text-[11px]"> <div className="flex justify-between text-[11px]">
<span className="text-slate-500">device_ratio:</span> <span className="text-slate-500">
{tAnalyticsModal("metricDeviceRatio")}:
</span>
<span className="text-slate-300 font-medium"> <span className="text-slate-300 font-medium">
💻 {desktopCount} <span className="text-slate-600">/</span> 📱{" "} 💻 {desktopCount} <span className="text-slate-600">/</span> 📱{" "}
{mobileCount} {mobileCount}
@ -196,9 +203,9 @@ export default function AnalyticsModal({
{/* GDPR & Activity Footer */} {/* GDPR & Activity Footer */}
<div className="mt-4 text-[10px] text-slate-600 flex justify-between items-center relative z-10 pt-2 border-t border-slate-800/40"> <div className="mt-4 text-[10px] text-slate-600 flex justify-between items-center relative z-10 pt-2 border-t border-slate-800/40">
<span>GDPR_ANONYMIZED: TRUE</span> <span>{tAnalyticsModal("gdprAnonymized")}</span>
<span className="text-slate-400 font-medium"> <span className="text-slate-400 font-medium">
LAST_PING:{" "} {tAnalyticsModal("lastPing")}:{" "}
<span className="text-slate-300">{getTimeAgo(stats.lastPing)}</span> <span className="text-slate-300">{getTimeAgo(stats.lastPing)}</span>
</span> </span>
</div> </div>

View file

@ -1,12 +1,15 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import AnalyticsModal, { AnalyticsData } from "./AnalyticsModal"; import AnalyticsModal, { AnalyticsData } from "./AnalyticsModal";
/** /**
* Footer badge widget that fetches live metrics and triggers the analytics modal * Footer badge widget that fetches live metrics and triggers the analytics modal
*/ */
export default function AnalyticsWidget() { export default function AnalyticsWidget() {
const tAnalyticsWidget = useTranslations("AnalyticsWidget");
const [stats, setStats] = useState<AnalyticsData | null>(null); const [stats, setStats] = useState<AnalyticsData | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@ -26,14 +29,14 @@ export default function AnalyticsWidget() {
// Open modal and silently refresh stats without incrementing view count // Open modal and silently refresh stats without incrementing view count
const handleOpenModal = () => { const handleOpenModal = () => {
fetchStats(false); // track=false when clicking badge fetchStats(false);
setIsOpen(true); setIsOpen(true);
}; };
// Hydrate client, fetch initial page metrics, and listen for custom Cmd+K palette events // Hydrate client, fetch initial page metrics, and listen for custom Cmd+K palette events
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
fetchStats(true); // Track initial page view fetchStats(true);
// Listen for custom trigger event fired from CommandPalette // Listen for custom trigger event fired from CommandPalette
const handleCustomOpen = () => handleOpenModal(); const handleCustomOpen = () => handleOpenModal();
@ -50,7 +53,9 @@ export default function AnalyticsWidget() {
<div className="h-7 w-52 font-mono text-[11px] text-slate-500/80 bg-slate-900/40 rounded-md border border-slate-800/60 flex items-center px-2.5 gap-1.5 select-none"> <div className="h-7 w-52 font-mono text-[11px] text-slate-500/80 bg-slate-900/40 rounded-md border border-slate-800/60 flex items-center px-2.5 gap-1.5 select-none">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400/80 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-amber-400/80 animate-pulse" />
<span className="text-blue-400 font-semibold">$</span> <span className="text-blue-400 font-semibold">$</span>
<span className="text-slate-400 animate-pulse">loading_stats...</span> <span className="text-slate-400 animate-pulse">
{tAnalyticsWidget("loading")}
</span>
</div> </div>
); );
} }
@ -61,7 +66,7 @@ export default function AnalyticsWidget() {
<button <button
onClick={handleOpenModal} onClick={handleOpenModal}
className="font-mono text-xs text-slate-500 hover:text-slate-300 transition-all flex items-center gap-1.5 cursor-pointer group bg-slate-900/40 hover:bg-slate-900 px-2.5 py-1 rounded-md border border-slate-800/60 hover:border-blue-500/40 shadow-sm" className="font-mono text-xs text-slate-500 hover:text-slate-300 transition-all flex items-center gap-1.5 cursor-pointer group bg-slate-900/40 hover:bg-slate-900 px-2.5 py-1 rounded-md border border-slate-800/60 hover:border-blue-500/40 shadow-sm"
title="Open analytics terminal" title={tAnalyticsWidget("tooltip")}
> >
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-blue-400 font-semibold">$</span> <span className="text-blue-400 font-semibold">$</span>
@ -70,14 +75,14 @@ export default function AnalyticsWidget() {
</span> </span>
<span className="text-slate-600">[</span> <span className="text-slate-600">[</span>
<span className="text-slate-300 group-hover:text-blue-400 transition-colors"> <span className="text-slate-300 group-hover:text-blue-400 transition-colors">
today: {tAnalyticsWidget("today")}:
<span className="text-emerald-400 font-medium"> <span className="text-emerald-400 font-medium">
{stats.todayViews} {stats.todayViews}
</span> </span>
</span> </span>
<span className="text-slate-600">|</span> <span className="text-slate-600">|</span>
<span className="text-slate-300 group-hover:text-blue-400 transition-colors"> <span className="text-slate-300 group-hover:text-blue-400 transition-colors">
total: {tAnalyticsWidget("total")}:
<span className="text-blue-400 font-medium">{stats.totalViews}</span> <span className="text-blue-400 font-medium">{stats.totalViews}</span>
</span> </span>
<span className="text-slate-600">]</span> <span className="text-slate-600">]</span>

View file

@ -71,6 +71,30 @@
"Footer": { "Footer": {
"rights": "Alle Rechte vorbehalten." "rights": "Alle Rechte vorbehalten."
}, },
"AnalyticsWidget": {
"loading": "laden_statistiken...",
"tooltip": "Statistik-Terminal öffnen",
"today": "heute",
"total": "insgesamt"
},
"AnalyticsModal": {
"tooltipClose": "Fenster schließen",
"tooltipMinimize": "Fenster minimieren",
"tooltipActive": "Aktiver Prozess",
"tooltipEsc": "Modal schließen (Esc)",
"statusOnline": "STATUS: ONLINE",
"latency": "LATENZ",
"metricViewsToday": "aufrufe_heute",
"metricUniqueVisitors": "einmalige_besucher",
"metricTotalHits": "gesamte_zugriffe",
"metricDeviceRatio": "geraete_verteilung",
"gdprAnonymized": "DSGVO_ANONYMISIERT: TRUE",
"lastPing": "LETZTER_PING",
"timeJustNow": "gerade eben",
"timeSecondsAgo": "vor {seconds}s",
"timeMinutesAgo": "vor {minutes}m",
"timeHoursAgo": "vor {hours}h"
},
"Imprint": { "Imprint": {
"backHome": "Zurück zur Startseite", "backHome": "Zurück zur Startseite",
"section1Title": "Angaben gemäß § 5 DDG", "section1Title": "Angaben gemäß § 5 DDG",

View file

@ -72,6 +72,30 @@
"Footer": { "Footer": {
"rights": "All rights reserved." "rights": "All rights reserved."
}, },
"AnalyticsWidget": {
"loading": "loading_stats...",
"tooltip": "Open analytics terminal",
"today": "today",
"total": "total"
},
"AnalyticsModal": {
"tooltipClose": "Close window",
"tooltipMinimize": "Minimize window",
"tooltipActive": "Active Process",
"tooltipEsc": "Close modal (Esc)",
"statusOnline": "STATUS: ONLINE",
"latency": "LATENCY",
"metricViewsToday": "views_today",
"metricUniqueVisitors": "unique_visitors",
"metricTotalHits": "total_hits",
"metricDeviceRatio": "device_ratio",
"gdprAnonymized": "GDPR_ANONYMIZED: TRUE",
"lastPing": "LAST_PING",
"timeJustNow": "just now",
"timeSecondsAgo": "{seconds}s ago",
"timeMinutesAgo": "{minutes}m ago",
"timeHoursAgo": "{hours}h ago"
},
"Imprint": { "Imprint": {
"section1Title": "Information Pursuant to § 5 DDG", "section1Title": "Information Pursuant to § 5 DDG",
"section1Sub": "Also responsible for content pursuant to § 18 Abs. 2 MStV.", "section1Sub": "Also responsible for content pursuant to § 18 Abs. 2 MStV.",