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 { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Analytics metrics payload returned by the custom backend API
|
||||
|
|
@ -24,20 +25,6 @@ interface AnalyticsModalProps {
|
|||
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
|
||||
*/
|
||||
|
|
@ -46,6 +33,22 @@ export default function AnalyticsModal({
|
|||
onClose,
|
||||
stats,
|
||||
}: 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)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -86,16 +89,16 @@ export default function AnalyticsModal({
|
|||
<button
|
||||
onClick={onClose}
|
||||
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
|
||||
onClick={onClose}
|
||||
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
|
||||
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>
|
||||
|
||||
|
|
@ -112,7 +115,7 @@ export default function AnalyticsModal({
|
|||
<button
|
||||
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"
|
||||
title="Close modal (Esc)"
|
||||
title={tAnalyticsModal("tooltipEsc")}
|
||||
>
|
||||
[esc ✕]
|
||||
</button>
|
||||
|
|
@ -126,10 +129,10 @@ export default function AnalyticsModal({
|
|||
</div>
|
||||
<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>STATUS: ONLINE</span>
|
||||
<span>{tAnalyticsModal("statusOnline")}</span>
|
||||
<span className="text-slate-700">•</span>
|
||||
<span className="text-slate-500">
|
||||
LATENCY:{" "}
|
||||
{tAnalyticsModal("latency")}:{" "}
|
||||
<span className="text-slate-300">
|
||||
{stats.latencyMs ?? "<1"}ms
|
||||
</span>
|
||||
|
|
@ -141,7 +144,7 @@ export default function AnalyticsModal({
|
|||
<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="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>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-emerald-400 tracking-tight">
|
||||
|
|
@ -163,7 +166,9 @@ export default function AnalyticsModal({
|
|||
{/* 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="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">
|
||||
{stats.totalViews}
|
||||
</span>
|
||||
|
|
@ -172,7 +177,9 @@ export default function AnalyticsModal({
|
|||
{/* Device ratio progress bar */}
|
||||
<div className="pt-2 border-t border-slate-800/80 space-y-1.5">
|
||||
<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">
|
||||
💻 {desktopCount} <span className="text-slate-600">/</span> 📱{" "}
|
||||
{mobileCount}
|
||||
|
|
@ -196,9 +203,9 @@ export default function AnalyticsModal({
|
|||
|
||||
{/* 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">
|
||||
<span>GDPR_ANONYMIZED: TRUE</span>
|
||||
<span>{tAnalyticsModal("gdprAnonymized")}</span>
|
||||
<span className="text-slate-400 font-medium">
|
||||
LAST_PING:{" "}
|
||||
{tAnalyticsModal("lastPing")}:{" "}
|
||||
<span className="text-slate-300">{getTimeAgo(stats.lastPing)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import AnalyticsModal, { AnalyticsData } from "./AnalyticsModal";
|
||||
|
||||
/**
|
||||
* Footer badge widget that fetches live metrics and triggers the analytics modal
|
||||
*/
|
||||
export default function AnalyticsWidget() {
|
||||
const tAnalyticsWidget = useTranslations("AnalyticsWidget");
|
||||
|
||||
const [stats, setStats] = useState<AnalyticsData | null>(null);
|
||||
const [isOpen, setIsOpen] = 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
|
||||
const handleOpenModal = () => {
|
||||
fetchStats(false); // track=false when clicking badge
|
||||
fetchStats(false);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Hydrate client, fetch initial page metrics, and listen for custom Cmd+K palette events
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
fetchStats(true); // Track initial page view
|
||||
fetchStats(true);
|
||||
|
||||
// Listen for custom trigger event fired from CommandPalette
|
||||
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">
|
||||
<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-slate-400 animate-pulse">loading_stats...</span>
|
||||
<span className="text-slate-400 animate-pulse">
|
||||
{tAnalyticsWidget("loading")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,7 +66,7 @@ export default function AnalyticsWidget() {
|
|||
<button
|
||||
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"
|
||||
title="Open analytics terminal"
|
||||
title={tAnalyticsWidget("tooltip")}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-blue-400 font-semibold">$</span>
|
||||
|
|
@ -70,14 +75,14 @@ export default function AnalyticsWidget() {
|
|||
</span>
|
||||
<span className="text-slate-600">[</span>
|
||||
<span className="text-slate-300 group-hover:text-blue-400 transition-colors">
|
||||
today:
|
||||
{tAnalyticsWidget("today")}:
|
||||
<span className="text-emerald-400 font-medium">
|
||||
{stats.todayViews}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-slate-600">|</span>
|
||||
<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>
|
||||
<span className="text-slate-600">]</span>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,30 @@
|
|||
"Footer": {
|
||||
"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": {
|
||||
"backHome": "Zurück zur Startseite",
|
||||
"section1Title": "Angaben gemäß § 5 DDG",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,30 @@
|
|||
"Footer": {
|
||||
"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": {
|
||||
"section1Title": "Information Pursuant to § 5 DDG",
|
||||
"section1Sub": "Also responsible for content pursuant to § 18 Abs. 2 MStV.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue