feat: add localization for analytics widget and analytics modal
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 25s
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 25s
This commit is contained in:
parent
ea2defe0f0
commit
ad4d4b4387
4 changed files with 92 additions and 32 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue