From 58648fd484c2fcb9af2591c00351209487c7537f Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 21 Feb 2026 09:14:39 +0100 Subject: [PATCH 1/6] feat: implement Speed Insight component for real-time PageSpeed analysis --- app/api/speed-insight/route.ts | 126 +++++++++ app/page.tsx | 22 ++ .../speed-insight/SpeedInsight.constants.ts | 24 ++ .../speed-insight/SpeedInsight.logic.ts | 117 +++++++++ components/speed-insight/SpeedInsight.tsx | 247 ++++++++++++++++++ components/speed-insight/index.ts | 6 + components/ui/tabs.tsx | 29 +- messages/de.json | 17 ++ messages/en.json | 17 ++ messages/es.json | 17 ++ messages/fr.json | 17 ++ messages/sv.json | 17 ++ types/configs/speed-insight.ts | 62 +++++ 13 files changed, 706 insertions(+), 12 deletions(-) create mode 100644 app/api/speed-insight/route.ts create mode 100644 components/speed-insight/SpeedInsight.constants.ts create mode 100644 components/speed-insight/SpeedInsight.logic.ts create mode 100644 components/speed-insight/SpeedInsight.tsx create mode 100644 components/speed-insight/index.ts create mode 100644 types/configs/speed-insight.ts diff --git a/app/api/speed-insight/route.ts b/app/api/speed-insight/route.ts new file mode 100644 index 0000000..c9bcaaa --- /dev/null +++ b/app/api/speed-insight/route.ts @@ -0,0 +1,126 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@/lib/security"; +import type { + RawPageSpeedResponse, + SpeedInsightResult, + SpeedInsightScore, + SpeedInsightApiResponse, +} from "@/types/configs/speed-insight"; + +const PAGESPEED_API_URL = + "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"; +const TARGET_URL = + process.env.PAGESPEED_TARGET_URL || "https://coldbydefault.com"; +const API_KEY = process.env.GOOGLE_PAGESPEED_API_KEY; + +/** Map a 0–1 score to a Tailwind color class */ +function getScoreColor(score: number): string { + if (score >= 90) return "text-green-500"; + if (score >= 50) return "text-yellow-500"; + return "text-red-500"; +} + +/** Parse the raw Google API response into our clean type */ +function parseResult( + raw: RawPageSpeedResponse, + strategy: "mobile" | "desktop", +): SpeedInsightResult { + const cats = raw.lighthouseResult.categories; + + const categories: SpeedInsightScore[] = Object.values(cats).map((cat) => { + const pct = Math.round((cat.score ?? 0) * 100); + return { + label: cat.title, + score: pct, + color: getScoreColor(pct), + }; + }); + + return { + url: raw.id, + strategy, + categories, + fetchedAt: raw.lighthouseResult.fetchTime, + }; +} + +/** Fetch PageSpeed data for a given strategy */ +async function fetchPageSpeed( + strategy: "mobile" | "desktop", +): Promise { + const params = new URLSearchParams({ + url: TARGET_URL, + strategy, + category: "performance", + }); + + // Add all categories + ["accessibility", "best-practices", "seo"].forEach((cat) => + params.append("category", cat), + ); + + if (API_KEY) { + params.set("key", API_KEY); + } + + const response = await fetch(`${PAGESPEED_API_URL}?${params.toString()}`, { + headers: { + Referer: TARGET_URL, + }, + next: { revalidate: 3600 }, // Cache for 1 hour + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `PageSpeed API error (${strategy}): ${response.status} – ${errorText}`, + ); + } + + const data = (await response.json()) as RawPageSpeedResponse; + return parseResult(data, strategy); +} + +export async function GET(): Promise { + try { + const [desktop, mobile] = await Promise.all([ + fetchPageSpeed("desktop"), + fetchPageSpeed("mobile"), + ]); + + const body: SpeedInsightApiResponse = { desktop, mobile }; + + return NextResponse.json(body, { + headers: { + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=7200", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + }, + }); + } catch (error) { + console.error("PageSpeed API Error:", error); + + return NextResponse.json( + { + error: "Failed to fetch PageSpeed data", + message: sanitizeErrorMessage(error), + desktop: null, + mobile: null, + } satisfies SpeedInsightApiResponse & { message: string }, + { + status: 500, + headers: { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }, + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 803ef95..215ad95 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -48,6 +48,17 @@ const ClientBackground = dynamic( }, ); +const SpeedInsight = dynamic( + () => + import("@/components/speed-insight").then((mod) => ({ + default: mod.SpeedInsight, + })), + { + loading: () => , + ssr: false, + }, +); + export default function Home() { const t = useTranslations("Home"); const tt = useTranslations("Services"); @@ -75,6 +86,17 @@ export default function Home() {
{/* Content Container */}
+ {/* PageSpeed Insights Section */} + + +
+ } + > + + + diff --git a/components/speed-insight/SpeedInsight.constants.ts b/components/speed-insight/SpeedInsight.constants.ts new file mode 100644 index 0000000..8a91a96 --- /dev/null +++ b/components/speed-insight/SpeedInsight.constants.ts @@ -0,0 +1,24 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +/** Score threshold boundaries for color coding */ +export const SCORE_THRESHOLDS = { + good: 90, + average: 50, +} as const; + +/** Circle progress ring dimensions */ +export const RING = { + size: 60, + strokeWidth: 5, + radius: 25, + circumference: 2 * Math.PI * 25, +} as const; + +/** API endpoint path */ +export const SPEED_INSIGHT_API = "/api/speed-insight" as const; + +/** Cache duration in milliseconds (matches API revalidate: 1 hour) */ +export const CACHE_DURATION_MS = 3_600_000 as const; diff --git a/components/speed-insight/SpeedInsight.logic.ts b/components/speed-insight/SpeedInsight.logic.ts new file mode 100644 index 0000000..3261854 --- /dev/null +++ b/components/speed-insight/SpeedInsight.logic.ts @@ -0,0 +1,117 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import type { + SpeedInsightApiResponse, + SpeedInsightResult, +} from "@/types/configs/speed-insight"; +import { SPEED_INSIGHT_API, CACHE_DURATION_MS } from "./SpeedInsight.constants"; + +/** State shape for the SpeedInsight hook */ +interface SpeedInsightState { + desktop: SpeedInsightResult | null; + mobile: SpeedInsightResult | null; + loading: boolean; + error: string | null; +} + +/** Return type for the useSpeedInsight hook */ +interface UseSpeedInsightReturn extends SpeedInsightState { + refetch: () => Promise; +} + +/** Cached response to avoid redundant API calls */ +let cachedData: SpeedInsightApiResponse | null = null; +let cacheTimestamp = 0; + +/** Check if cached data is still valid */ +function isCacheValid(): boolean { + return cachedData !== null && Date.now() - cacheTimestamp < CACHE_DURATION_MS; +} + +/** + * Hook to fetch and manage PageSpeed Insights data + * Includes caching, error handling, and refetch capability + */ +export function useSpeedInsight(): UseSpeedInsightReturn { + const [state, setState] = useState({ + desktop: null, + mobile: null, + loading: true, + error: null, + }); + + const abortRef = useRef(null); + + const fetchData = useCallback(async (force = false): Promise => { + // Return cached data if valid and not forced + if (!force && isCacheValid() && cachedData) { + setState({ + desktop: cachedData.desktop, + mobile: cachedData.mobile, + loading: false, + error: null, + }); + return; + } + + // Abort any in-flight request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setState((prev) => ({ ...prev, loading: true, error: null })); + + try { + const response = await fetch(SPEED_INSIGHT_API, { + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = (await response.json()) as SpeedInsightApiResponse; + + if (data.error) { + throw new Error(data.error); + } + + // Update cache + cachedData = data; + cacheTimestamp = Date.now(); + + setState({ + desktop: data.desktop, + mobile: data.mobile, + loading: false, + error: null, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + + setState((prev) => ({ + ...prev, + loading: false, + error: err instanceof Error ? err.message : "Unknown error", + })); + } + }, []); + + const refetch = useCallback(async (): Promise => { + await fetchData(true); + }, [fetchData]); + + useEffect(() => { + void fetchData(); + + return () => { + abortRef.current?.abort(); + }; + }, [fetchData]); + + return { ...state, refetch }; +} diff --git a/components/speed-insight/SpeedInsight.tsx b/components/speed-insight/SpeedInsight.tsx new file mode 100644 index 0000000..df05124 --- /dev/null +++ b/components/speed-insight/SpeedInsight.tsx @@ -0,0 +1,247 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +"use client"; + +import { useTranslations } from "next-intl"; +import { + Monitor, + Smartphone, + RefreshCw, + AlertCircle, + Globe, +} from "lucide-react"; +import { SiGoogle } from "react-icons/si"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import type { SpeedInsightScore } from "@/types/configs/speed-insight"; +import { useSpeedInsight } from "./SpeedInsight.logic"; +import { RING, SCORE_THRESHOLDS } from "./SpeedInsight.constants"; + +/** Calculate stroke-dashoffset for the ring gauge */ +function getOffset(score: number): number { + return RING.circumference - (score / 100) * RING.circumference; +} + +/** Map score to ring color */ +function getRingColor(score: number): string { + if (score >= SCORE_THRESHOLDS.good) return "stroke-green-500"; + if (score >= SCORE_THRESHOLDS.average) return "stroke-yellow-500"; + return "stroke-red-500"; +} + +/** Single score ring gauge */ +function ScoreRing({ category }: { category: SpeedInsightScore }) { + const offset = getOffset(category.score); + + return ( +
+
+ + {/* Background ring */} + + {/* Score ring */} + + + {/* Score number */} + +
+ + {category.label} + +
+ ); +} + +/** Loading skeleton for a strategy tab */ +function StrategySkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ ); +} + +export default function SpeedInsight({ className }: { className?: string }) { + const t = useTranslations("Home.speedInsight"); + const { desktop, mobile, loading, error, refetch } = useSpeedInsight(); + + return ( +
+ + + {/* Header row */} +
+
+
+ +
+
+ + {t("title")} + + + {t("subtitle")} + +
+
+ + {/* Live indicator */} + {!error && !loading && ( + + + + + + {t("liveLabel")} + + )} +
+ + {/* Portfolio URL indicator */} +
+ + {t("analyzingPortfolio")} +
+
+ + + {/* Error state */} + {error && !loading && ( +
+ +

{t("error")}

+ +
+ )} + + {/* Data / Loading */} + {!error && ( + +
+ + + + {t("desktop")} + + + + {t("mobile")} + + + + +
+ + + {loading ? ( + + ) : desktop ? ( +
+ {desktop.categories.map((cat) => ( + + ))} +
+ ) : null} +
+ + + {loading ? ( + + ) : mobile ? ( +
+ {mobile.categories.map((cat) => ( + + ))} +
+ ) : null} +
+
+ )} + + {/* Footer */} +
+
+ + {t("poweredBy")} +
+ {desktop?.fetchedAt && ( + + {t("lastUpdated")}:{" "} + {new Date(desktop.fetchedAt).toLocaleTimeString()} + + )} +
+
+
+
+ ); +} diff --git a/components/speed-insight/index.ts b/components/speed-insight/index.ts new file mode 100644 index 0000000..04db8bd --- /dev/null +++ b/components/speed-insight/index.ts @@ -0,0 +1,6 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +export { default as SpeedInsight } from "./SpeedInsight"; diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx index 497ba5e..40bb9a7 100644 --- a/components/ui/tabs.tsx +++ b/components/ui/tabs.tsx @@ -1,9 +1,14 @@ -"use client" +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" +"use client"; -import { cn } from "@/lib/utils" +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; function Tabs({ className, @@ -15,7 +20,7 @@ function Tabs({ className={cn("flex flex-col gap-2", className)} {...props} /> - ) + ); } function TabsList({ @@ -26,12 +31,12 @@ function TabsList({ - ) + ); } function TabsTrigger({ @@ -43,11 +48,11 @@ function TabsTrigger({ data-slot="tabs-trigger" className={cn( "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - className + className, )} {...props} /> - ) + ); } function TabsContent({ @@ -60,7 +65,7 @@ function TabsContent({ className={cn("flex-1 outline-none", className)} {...props} /> - ) + ); } -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/messages/de.json b/messages/de.json index 26cee11..246134b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4,6 +4,23 @@ "companiesContributing": "Unternehmen, bei denen ich arbeite / Projekte, zu denen ich beitrage:", "services": { "title": "Leistungen & Pakete" + }, + "speedInsight": { + "title": "Live PageSpeed-Ergebnisse", + "subtitle": "Echtzeit Google Lighthouse-Bewertungen für diese Website", + "desktop": "Desktop", + "mobile": "Mobil", + "performance": "Leistung", + "accessibility": "Barrierefreiheit", + "bestPractices": "Best Practices", + "seo": "SEO", + "loading": "Website-Leistung wird analysiert...", + "error": "PageSpeed-Daten konnten nicht geladen werden", + "retry": "Erneut versuchen", + "poweredBy": "Bereitgestellt von Google PageSpeed Insights", + "lastUpdated": "Zuletzt aktualisiert", + "liveLabel": "Live", + "analyzingPortfolio": "Ergebnisse für coldbydefault.com" } }, "Hero": { diff --git a/messages/en.json b/messages/en.json index 202ac53..d6cc4c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4,6 +4,23 @@ "companiesContributing": "Companies I'm working at / Projects I'm contributing to:", "services": { "title": "Services & Packages" + }, + "speedInsight": { + "title": "Live PageSpeed Insights", + "subtitle": "Real-time Google Lighthouse scores for this website", + "desktop": "Desktop", + "mobile": "Mobile", + "performance": "Performance", + "accessibility": "Accessibility", + "bestPractices": "Best Practices", + "seo": "SEO", + "loading": "Analyzing website performance...", + "error": "Unable to load PageSpeed data", + "retry": "Retry", + "poweredBy": "Powered by Google PageSpeed Insights", + "lastUpdated": "Last updated", + "liveLabel": "Live", + "analyzingPortfolio": "Scores for coldbydefault.com" } }, "Hero": { diff --git a/messages/es.json b/messages/es.json index 2db9bd3..349d380 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4,6 +4,23 @@ "companiesContributing": "Empresas donde trabajo / Proyectos a los que contribuyo:", "services": { "title": "Servicios y Paquetes" + }, + "speedInsight": { + "title": "PageSpeed Insights en vivo", + "subtitle": "Puntuaciones de Google Lighthouse en tiempo real para este sitio web", + "desktop": "Escritorio", + "mobile": "Móvil", + "performance": "Rendimiento", + "accessibility": "Accesibilidad", + "bestPractices": "Mejores prácticas", + "seo": "SEO", + "loading": "Analizando el rendimiento del sitio web...", + "error": "No se pudieron cargar los datos de PageSpeed", + "retry": "Reintentar", + "poweredBy": "Impulsado por Google PageSpeed Insights", + "lastUpdated": "Última actualización", + "liveLabel": "En vivo", + "analyzingPortfolio": "Puntuaciones de coldbydefault.com" } }, "Hero": { diff --git a/messages/fr.json b/messages/fr.json index 8d75cdb..94d8bd0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4,6 +4,23 @@ "companiesContributing": "Entreprises où je travaille / Projets auxquels je contribue :", "services": { "title": "Services & Offres" + }, + "speedInsight": { + "title": "PageSpeed Insights en direct", + "subtitle": "Scores Google Lighthouse en temps réel pour ce site web", + "desktop": "Bureau", + "mobile": "Mobile", + "performance": "Performance", + "accessibility": "Accessibilité", + "bestPractices": "Bonnes pratiques", + "seo": "SEO", + "loading": "Analyse des performances du site web...", + "error": "Impossible de charger les données PageSpeed", + "retry": "Réessayer", + "poweredBy": "Propulsé par Google PageSpeed Insights", + "lastUpdated": "Dernière mise à jour", + "liveLabel": "En direct", + "analyzingPortfolio": "Scores de coldbydefault.com" } }, "Hero": { diff --git a/messages/sv.json b/messages/sv.json index e456a47..de7b9bf 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4,6 +4,23 @@ "companiesContributing": "Företag där jag arbetar / Projekt jag bidrar till:", "services": { "title": "Tjänster & Paket" + }, + "speedInsight": { + "title": "Live PageSpeed-resultat", + "subtitle": "Google Lighthouse-poäng i realtid för denna webbplats", + "desktop": "Dator", + "mobile": "Mobil", + "performance": "Prestanda", + "accessibility": "Tillgänglighet", + "bestPractices": "Bästa praxis", + "seo": "SEO", + "loading": "Analyserar webbplatsens prestanda...", + "error": "Kunde inte ladda PageSpeed-data", + "retry": "Försök igen", + "poweredBy": "Drivs av Google PageSpeed Insights", + "lastUpdated": "Senast uppdaterad", + "liveLabel": "Live", + "analyzingPortfolio": "Resultat för coldbydefault.com" } }, "Hero": { diff --git a/types/configs/speed-insight.ts b/types/configs/speed-insight.ts new file mode 100644 index 0000000..d81a550 --- /dev/null +++ b/types/configs/speed-insight.ts @@ -0,0 +1,62 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +/** Individual Lighthouse audit category */ +export interface SpeedInsightCategory { + /** Category identifier */ + id: string; + /** Display title */ + title: string; + /** Score from 0 to 1 (multiply by 100 for percentage) */ + score: number | null; +} + +/** Parsed result for a single category */ +export interface SpeedInsightScore { + /** Category name (e.g., "Performance") */ + label: string; + /** Score as percentage (0–100) */ + score: number; + /** Color class based on score threshold */ + color: string; +} + +/** Full parsed PageSpeed result */ +export interface SpeedInsightResult { + /** URL that was analyzed */ + url: string; + /** Strategy used: mobile or desktop */ + strategy: "mobile" | "desktop"; + /** Array of category scores */ + categories: SpeedInsightScore[]; + /** ISO timestamp of the analysis */ + fetchedAt: string; +} + +/** API response shape from our internal route */ +export interface SpeedInsightApiResponse { + /** Desktop analysis result */ + desktop: SpeedInsightResult | null; + /** Mobile analysis result */ + mobile: SpeedInsightResult | null; + /** Error message if the request failed */ + error?: string; +} + +/** Raw Google PageSpeed Insights API category shape */ +export interface RawLighthouseCategory { + id: string; + title: string; + score: number | null; +} + +/** Raw Google PageSpeed Insights API response (partial) */ +export interface RawPageSpeedResponse { + id: string; + lighthouseResult: { + categories: Record; + fetchTime: string; + }; +} From 1c855d4778fb533f57c86968c2722da102c143f3 Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 21 Feb 2026 09:29:16 +0100 Subject: [PATCH 2/6] refactor: improve layout and styling of ScoreRing and SpeedInsight components --- components/speed-insight/SpeedInsight.tsx | 80 ++++++++++++----------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/components/speed-insight/SpeedInsight.tsx b/components/speed-insight/SpeedInsight.tsx index df05124..112d08e 100644 --- a/components/speed-insight/SpeedInsight.tsx +++ b/components/speed-insight/SpeedInsight.tsx @@ -46,19 +46,18 @@ function ScoreRing({ category }: { category: SpeedInsightScore }) { const offset = getOffset(category.score); return ( -
-
+
+
{/* Background ring */} {/* Score ring */} {/* Score number */}
- + {category.label}
@@ -95,14 +94,11 @@ function ScoreRing({ category }: { category: SpeedInsightScore }) { /** Loading skeleton for a strategy tab */ function StrategySkeleton() { return ( -
+
{Array.from({ length: 4 }).map((_, i) => ( -
- - +
+ +
))}
@@ -119,15 +115,15 @@ export default function SpeedInsight({ className }: { className?: string }) { {/* Header row */}
-
-
- +
+
+
- + {t("title")} - + {t("subtitle")}
@@ -137,11 +133,11 @@ export default function SpeedInsight({ className }: { className?: string }) { {!error && !loading && ( - + - + {t("liveLabel")} @@ -176,13 +172,19 @@ export default function SpeedInsight({ className }: { className?: string }) { {!error && (
- - - + + + {t("desktop")} - - + + {t("mobile")} @@ -205,7 +207,7 @@ export default function SpeedInsight({ className }: { className?: string }) { {loading ? ( ) : desktop ? ( -
+
{desktop.categories.map((cat) => ( ))} @@ -217,7 +219,7 @@ export default function SpeedInsight({ className }: { className?: string }) { {loading ? ( ) : mobile ? ( -
+
{mobile.categories.map((cat) => ( ))} @@ -228,14 +230,14 @@ export default function SpeedInsight({ className }: { className?: string }) { )} {/* Footer */} -
-
- - {t("poweredBy")} +
+
+ + {t("poweredBy")}
{desktop?.fetchedAt && ( - - {t("lastUpdated")}:{" "} + + {t("lastUpdated")}: {new Date(desktop.fetchedAt).toLocaleTimeString()} )} From 72319c2a6481c99d27d41df2afccfafe678243b5 Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 21 Feb 2026 10:04:06 +0100 Subject: [PATCH 3/6] feat: implement LocaleAutoDetect component for improved language detection and user experience --- app/layout.tsx | 4 +- .../languages/browser-translation-notice.tsx | 181 ------------ components/languages/index.ts | 4 +- components/languages/language-switcher.tsx | 14 +- components/languages/locale-auto-detect.tsx | 257 ++++++++++++++++++ messages/de.json | 10 + messages/en.json | 10 + messages/es.json | 10 + messages/fr.json | 10 + messages/sv.json | 10 + proxy.ts | 6 +- 11 files changed, 323 insertions(+), 193 deletions(-) delete mode 100644 components/languages/browser-translation-notice.tsx create mode 100644 components/languages/locale-auto-detect.tsx diff --git a/app/layout.tsx b/app/layout.tsx index db0e571..08af21d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -22,7 +22,7 @@ import { ThemeProvider } from "@/components/theme/theme-provider"; import { Navbar } from "@/components/nav"; import { Footer } from "@/components/footer"; import { CookiesBanner } from "@/components/cookies"; -import { BrowserTranslationNotice } from "@/components/languages"; +import { LocaleAutoDetect } from "@/components/languages"; import { ChatBot } from "@/components/chatbot"; import { NoSSR } from "@/components/NoSSR"; import { seoConfigEN, generateStructuredData } from "@/lib/configs/seo"; @@ -215,7 +215,7 @@ export default async function RootLayout({
- + diff --git a/components/languages/browser-translation-notice.tsx b/components/languages/browser-translation-notice.tsx deleted file mode 100644 index cb95bd8..0000000 --- a/components/languages/browser-translation-notice.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/** - * @author ColdByDefault - * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ - -"use client"; - -import { useState, useEffect, useSyncExternalStore } from "react"; -import { Button } from "@/components/ui/button"; -import { X, Languages, Info } from "lucide-react"; - -interface BrowserTranslationNoticeProps { - className?: string; -} - -const emptySubscribe = () => () => {}; - -function getCookie(name: string): string | undefined { - if (typeof document === "undefined") return undefined; - return document.cookie - .split("; ") - .find((row) => row.startsWith(`${name}=`)) - ?.split("=")[1]; -} - -const BrowserTranslationNotice = ({ - className = "", -}: BrowserTranslationNoticeProps) => { - const [isVisible, setIsVisible] = useState(false); - - const isClient = useSyncExternalStore( - emptySubscribe, - () => true, - () => false - ); - - const isDismissed = useSyncExternalStore( - emptySubscribe, - () => localStorage.getItem("translation-notice-dismissed") === "true", - () => true - ); - - const browserLang = useSyncExternalStore( - emptySubscribe, - () => getCookie("PORTFOLIOVERSIONLATEST_BROWSER_LANG") || "", - () => "" - ); - - const currentLocale = useSyncExternalStore( - emptySubscribe, - () => getCookie("PORTFOLIOVERSIONLATEST_LOCALE") || "en", - () => "en" - ); - - useEffect(() => { - if (!isClient || isDismissed) return; - - // Show notice if browser language is different from current locale - // or if browser language is unsupported - const supportedLocales = ["en", "de", "es", "fr", "sv"]; - const showNotice = - browserLang && - currentLocale && - (browserLang !== currentLocale || - !supportedLocales.includes(browserLang)); - - if (showNotice) { - // Delay showing the notice to avoid layout shift - const timer = setTimeout(() => setIsVisible(true), 2000); - return () => clearTimeout(timer); - } - return undefined; - }, [isClient, isDismissed, browserLang, currentLocale]); - - const handleDismiss = () => { - setIsVisible(false); - localStorage.setItem("translation-notice-dismissed", "true"); - }; - - const scrollToLanguageSwitcher = () => { - // Try to find and scroll to the language switcher - const languageSwitcher = document.querySelector( - '[aria-label*="Current language"]' - ); - if (languageSwitcher) { - languageSwitcher.scrollIntoView({ behavior: "smooth", block: "center" }); - // Briefly highlight the switcher with a more subtle effect - languageSwitcher.classList.add( - "ring-2", - "ring-blue-400/50", - "ring-offset-2" - ); - setTimeout(() => { - languageSwitcher.classList.remove( - "ring-2", - "ring-blue-400/50", - "ring-offset-2" - ); - }, 3000); - } - // Don't auto-dismiss, let user decide - }; - - if (!isVisible || isDismissed) { - return null; - } - - const supportedLocales = ["en", "de", "es", "fr", "sv"]; - const isUnsupportedBrowser = !supportedLocales.includes(browserLang); - const browserLangDisplay = - browserLang === "unknown" ? "UNKNOWN" : browserLang.toUpperCase(); - - return ( -
-
- -
-

Language Auto-detected

-

- {isUnsupportedBrowser ? ( - <> - - Browser: {browserLangDisplay} (unsupported). Using English. - - - Browser: {browserLangDisplay} (not supported). Using English. - - - ) : ( - <> - - Auto-switched to {currentLocale.toUpperCase()}. Use language - switcher for best experience. - - - Switched to {currentLocale.toUpperCase()}. Use switcher below - for best experience. - - - )} -

-
- - -
-
- -
-
- ); -}; - -export default BrowserTranslationNotice; diff --git a/components/languages/index.ts b/components/languages/index.ts index dbaecf1..c4872b7 100644 --- a/components/languages/index.ts +++ b/components/languages/index.ts @@ -1,7 +1,7 @@ /** * @author ColdByDefault * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ + */ export { default as LanguageSwitcher } from "./language-switcher"; -export { default as BrowserTranslationNotice } from "./browser-translation-notice"; +export { default as LocaleAutoDetect } from "./locale-auto-detect"; diff --git a/components/languages/language-switcher.tsx b/components/languages/language-switcher.tsx index cc16579..a69dad6 100644 --- a/components/languages/language-switcher.tsx +++ b/components/languages/language-switcher.tsx @@ -1,7 +1,7 @@ /** * @author ColdByDefault * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ + */ "use client"; @@ -48,11 +48,13 @@ function getInitialLocale(): string { function getInitialBrowserLang(): string { const cookieBrowserLang = getCookie("PORTFOLIOVERSIONLATEST_BROWSER_LANG"); - if (cookieBrowserLang) return cookieBrowserLang; + // Prefer navigator.language over cookie if cookie is "unknown" (server couldn't detect) + if (cookieBrowserLang && cookieBrowserLang !== "unknown") + return cookieBrowserLang; if (typeof navigator !== "undefined") { - return navigator.language.slice(0, 2); + return navigator.language.slice(0, 2).toLowerCase(); } - return "en"; + return cookieBrowserLang ?? "en"; } const LanguageSwitcher = () => { @@ -62,13 +64,13 @@ const LanguageSwitcher = () => { const initialLocale = useSyncExternalStore( emptySubscribe, getInitialLocale, - () => "en" + () => "en", ); const initialBrowserLang = useSyncExternalStore( emptySubscribe, getInitialBrowserLang, - () => "en" + () => "en", ); // State for user-changed values diff --git a/components/languages/locale-auto-detect.tsx b/components/languages/locale-auto-detect.tsx new file mode 100644 index 0000000..967d1c0 --- /dev/null +++ b/components/languages/locale-auto-detect.tsx @@ -0,0 +1,257 @@ +/** + * @author ColdByDefault + * @copyright 2026 ColdByDefault. All Rights Reserved. + */ + +"use client"; + +import { useEffect, useState, useCallback, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Languages, Globe, X } from "lucide-react"; + +/** Locales supported by the portfolio */ +const supportedLocales = ["en", "de", "es", "fr", "sv"] as const; +type SupportedLocale = (typeof supportedLocales)[number]; + +/** Language display names */ +const languageNames: Record = { + en: "English", + de: "Deutsch", + es: "Español", + fr: "Français", + sv: "Svenska", +}; + +/** Default locale when browser language is unsupported */ +const DEFAULT_LOCALE: SupportedLocale = "de"; + +/** Delay before showing the toast after page is ready (ms) */ +const TOAST_DELAY = 3000; + +const emptySubscribe = () => () => {}; + +function getCookie(name: string): string | undefined { + if (typeof document === "undefined") return undefined; + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + +function setCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; path=/; max-age=31536000; SameSite=Lax`; +} + +function isSupportedLocale(lang: string): lang is SupportedLocale { + return supportedLocales.includes(lang as SupportedLocale); +} + +/** + * Auto-detects browser language, sets the correct locale cookie, + * and shows a sleek toast telling the user the site adapted to them. + */ +const LocaleAutoDetect = () => { + const router = useRouter(); + const t = useTranslations("localeDetect"); + const [showToast, setShowToast] = useState(false); + const [exiting, setExiting] = useState(false); + const [toastData, setToastData] = useState({ + browserLang: "", + switchedTo: "", + isSupported: true, + serverDetected: false, + }); + + const isClient = useSyncExternalStore( + emptySubscribe, + () => true, + () => false, + ); + + const dismiss = useCallback(() => { + setExiting(true); + setTimeout(() => setShowToast(false), 400); + }, []); + + // Step 1: Fix locale if needed (runs once, survives refresh) + useEffect(() => { + if (!isClient) return; + // If we already fixed + showed, skip + if (sessionStorage.getItem("locale-detection-done") === "true") return; + + const browserLangCookie = getCookie("PORTFOLIOVERSIONLATEST_BROWSER_LANG"); + const currentLocale = + getCookie("PORTFOLIOVERSIONLATEST_LOCALE") ?? DEFAULT_LOCALE; + const browserLang = navigator.language?.split("-")[0]?.toLowerCase() ?? ""; + if (!browserLang) return; + + const needsClientFix = + !browserLangCookie || + browserLangCookie === "unknown" || + !isSupportedLocale(browserLangCookie); + + if (needsClientFix) { + setCookie("PORTFOLIOVERSIONLATEST_BROWSER_LANG", browserLang); + const detectedLocale = isSupportedLocale(browserLang) + ? browserLang + : DEFAULT_LOCALE; + + if (currentLocale !== detectedLocale) { + setCookie("PORTFOLIOVERSIONLATEST_LOCALE", detectedLocale); + // Store what we detected so we can show it after refresh + sessionStorage.setItem( + "locale-detection-result", + JSON.stringify({ + browserLang, + switchedTo: detectedLocale, + isSupported: isSupportedLocale(browserLang), + serverDetected: false, + }), + ); + router.refresh(); + return; + } + + // No locale change needed, store result for toast + sessionStorage.setItem( + "locale-detection-result", + JSON.stringify({ + browserLang, + switchedTo: detectedLocale, + isSupported: isSupportedLocale(browserLang), + serverDetected: false, + }), + ); + } else if (browserLangCookie) { + // Server detected correctly — store for toast + sessionStorage.setItem( + "locale-detection-result", + JSON.stringify({ + browserLang: browserLang || browserLangCookie, + switchedTo: currentLocale, + isSupported: isSupportedLocale(browserLang || browserLangCookie), + serverDetected: true, + }), + ); + } + }, [isClient, router]); + + // Step 2: Show toast (reads stored result, works after refresh too) + useEffect(() => { + if (!isClient) return; + if (sessionStorage.getItem("locale-detection-done") === "true") return; + + const stored = sessionStorage.getItem("locale-detection-result"); + if (!stored) return; + + const timer = setTimeout(() => { + try { + const data = JSON.parse(stored) as typeof toastData; + setToastData(data); + setShowToast(true); + setExiting(false); + sessionStorage.setItem("locale-detection-done", "true"); + } catch { + // Invalid JSON, skip + } + }, TOAST_DELAY); + + return () => clearTimeout(timer); + }, [isClient]); + + if (!showToast) return null; + + const langName = languageNames[toastData.switchedTo] ?? toastData.switchedTo; + const browserName = + languageNames[toastData.browserLang] ?? toastData.browserLang.toUpperCase(); + + return ( +
+
+ + +
+ {/* Icon */} +
+ {toastData.isSupported ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+

+ {t("title")} +

+ +

+ {toastData.isSupported ? ( + <> + {t("browserLanguage")}:{" "} + + {browserName} + + {toastData.browserLang !== toastData.switchedTo ? ( + <> + {" "} + — {t("switchedTo")}{" "} + + {langName} + + + ) : ( + <> + {" "} + —{" "} + + {t("matched")} + + + )} + + ) : ( + <> + {t("browser")}:{" "} + + {browserName} + {" "} + ({t("notSupported")}) — {t("using")}{" "} + + {langName} + + + )} +

+ + {toastData.serverDetected && ( +

+ + {t("serverDetected")} +

+ )} +
+
+
+
+ ); +}; + +export default LocaleAutoDetect; diff --git a/messages/de.json b/messages/de.json index 246134b..2c2f396 100644 --- a/messages/de.json +++ b/messages/de.json @@ -581,5 +581,15 @@ "placeholder": "Beispiel: Unsere Unternehmensrichtlinie sieht eine Antwort innerhalb von 24 Stunden vor. Wir bieten eine 30-tägige Geld-zurück-Garantie. Die Projektfrist ist der 15. März..." } } + }, + "localeDetect": { + "title": "Sprache erkannt", + "browserLanguage": "Browsersprache", + "browser": "Browser", + "switchedTo": "gewechselt zu", + "matched": "übereinstimmend", + "notSupported": "nicht unterstützt", + "using": "verwende", + "serverDetected": "Serverseitig erkannt" } } diff --git a/messages/en.json b/messages/en.json index d6cc4c2..b6224f5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -581,5 +581,15 @@ "placeholder": "Example: Our company policy is to respond within 24 hours. We offer 30-day money-back guarantees. The project deadline is March 15th..." } } + }, + "localeDetect": { + "title": "Language Detected", + "browserLanguage": "Browser language", + "browser": "Browser", + "switchedTo": "switched to", + "matched": "matched", + "notSupported": "not supported", + "using": "using", + "serverDetected": "Server-side detected" } } diff --git a/messages/es.json b/messages/es.json index 349d380..17c5921 100644 --- a/messages/es.json +++ b/messages/es.json @@ -581,5 +581,15 @@ "placeholder": "Ejemplo: Nuestra política de empresa es responder en 24 horas. Ofrecemos una garantía de devolución de 30 días. La fecha límite del proyecto es el 15 de marzo..." } } + }, + "localeDetect": { + "title": "Idioma detectado", + "browserLanguage": "Idioma del navegador", + "browser": "Navegador", + "switchedTo": "cambiado a", + "matched": "coincide", + "notSupported": "no soportado", + "using": "usando", + "serverDetected": "Detectado del servidor" } } diff --git a/messages/fr.json b/messages/fr.json index 94d8bd0..b0124c9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -581,5 +581,15 @@ "placeholder": "Exemple : La politique de notre entreprise est de répondre sous 24 heures. Nous offrons une garantie de remboursement de 30 jours. La date limite du projet est le 15 mars..." } } + }, + "localeDetect": { + "title": "Langue détectée", + "browserLanguage": "Langue du navigateur", + "browser": "Navigateur", + "switchedTo": "changé en", + "matched": "correspond", + "notSupported": "non supporté", + "using": "utilise", + "serverDetected": "Détecté côté serveur" } } diff --git a/messages/sv.json b/messages/sv.json index de7b9bf..e8f0e18 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -581,5 +581,15 @@ "placeholder": "Exempel: Vår företagspolicy är att svara inom 24 timmar. Vi erbjuder 30 dagars öppet köp. Projektets deadline är den 15 mars..." } } + }, + "localeDetect": { + "title": "Språk identifierat", + "browserLanguage": "Webbläsarspråk", + "browser": "Webbläsare", + "switchedTo": "bytt till", + "matched": "matchade", + "notSupported": "stöds ej", + "using": "använder", + "serverDetected": "Identifierat på serversidan" } } diff --git a/proxy.ts b/proxy.ts index fa421f7..7cdd078 100644 --- a/proxy.ts +++ b/proxy.ts @@ -127,7 +127,7 @@ function cleanupExpiredSessions(): void { * Sets PORTFOLIOVERSIONLATEST_LOCALE for i18n and PORTFOLIOVERSIONLATEST_BROWSER_LANG for UI hints * * Note: PORTFOLIOVERSIONLATEST_BROWSER_LANG is informational only (used by language-switcher - * and browser-translation-notice components). It should NOT gate the locale detection bypass. + * and locale-auto-detect components). It should NOT gate the locale detection bypass. */ function handleLocaleDetection(request: NextRequest): NextResponse | null { // Check if locale cookie already exists - this is the only required check @@ -144,7 +144,9 @@ function handleLocaleDetection(request: NextRequest): NextResponse | null { const acceptLanguage = request.headers.get("accept-language"); if (!acceptLanguage) { - // No language preference, set default and continue + // No Accept-Language header (privacy mode / stripped by browser) + // Fallback to default "de". Client-side LocaleAutoDetect component + // will correct this using navigator.language if needed. const response = NextResponse.next(); response.cookies.set("PORTFOLIOVERSIONLATEST_LOCALE", "de", { path: "/", From 7e7a2f9942fdbc5aad5c27a5224eb638aad7b16f Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 21 Feb 2026 10:07:09 +0100 Subject: [PATCH 4/6] feat: enhance CookiesBanner with internationalization support and improved privacy messaging --- components/cookies/cookies-banner.tsx | 27 +++++++++++++-------------- messages/de.json | 10 ++++++++++ messages/en.json | 10 ++++++++++ messages/es.json | 10 ++++++++++ messages/fr.json | 10 ++++++++++ messages/sv.json | 10 ++++++++++ 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/components/cookies/cookies-banner.tsx b/components/cookies/cookies-banner.tsx index 142b6e1..ef725e0 100644 --- a/components/cookies/cookies-banner.tsx +++ b/components/cookies/cookies-banner.tsx @@ -1,7 +1,7 @@ /** * @author ColdByDefault * @copyright 2026 ColdByDefault. All Rights Reserved. -*/ + */ "use client"; @@ -15,15 +15,17 @@ import { import { Button } from "@/components/ui/button"; import { X, Cookie } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useTranslations } from "next-intl"; const emptySubscribe = () => () => {}; export function CookiesBanner() { const [isVisible, setIsVisible] = useState(false); + const t = useTranslations("CookieBanner"); const mounted = useSyncExternalStore( emptySubscribe, () => true, - () => false + () => false, ); useEffect(() => { @@ -69,7 +71,7 @@ export function CookiesBanner() { className={cn( "fixed bottom-4 left-4 right-4 md:left-6 md:right-6 z-10", "animate-in slide-in-from-bottom-5 duration-500", - "max-w-md md:max-w-lg lg:max-w-xl ml-auto" + "max-w-md md:max-w-lg lg:max-w-xl ml-auto", )} > @@ -80,7 +82,7 @@ export function CookiesBanner() { onClick={handleClose} > - close + {t("close")} @@ -91,20 +93,17 @@ export function CookiesBanner() {
- We value your privacy + {t("title")} - I bake my own cookies! Theme preferences are stored locally in - your browser. I use Vercel Analytics and Speed Insights to - monitor performance, which are privacy-friendly and do not - track personal data.{" "} + {t("description")}{" "} - Learn more about privacy policy + {t("learnMore")}
@@ -116,7 +115,7 @@ export function CookiesBanner() { size="sm" className="flex-1 text-xs" > - Accept All Cookies + {t("acceptAll")}
diff --git a/messages/de.json b/messages/de.json index 2c2f396..5670b2d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -591,5 +591,15 @@ "notSupported": "nicht unterstützt", "using": "verwende", "serverDetected": "Serverseitig erkannt" + }, + "CookieBanner": { + "title": "Wir schätzen Ihre Privatsphäre", + "description": "Ich backe meine eigenen Cookies! Theme-Einstellungen werden lokal in Ihrem Browser gespeichert. Ich verwende Vercel Analytics und Speed Insights zur Leistungsüberwachung, die datenschutzfreundlich sind und keine personenbezogenen Daten erfassen.", + "learnMore": "Mehr über die Datenschutzrichtlinie erfahren", + "learnMoreAriaLabel": "Mehr über die Datenschutzrichtlinie und Cookie-Nutzung erfahren", + "acceptAll": "Alle Cookies akzeptieren", + "essentialOnly": "Nur essenzielle", + "declineAnalytics": "Analyse ablehnen", + "close": "Schließen" } } diff --git a/messages/en.json b/messages/en.json index b6224f5..2914b8f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -591,5 +591,15 @@ "notSupported": "not supported", "using": "using", "serverDetected": "Server-side detected" + }, + "CookieBanner": { + "title": "We value your privacy", + "description": "I bake my own cookies! Theme preferences are stored locally in your browser. I use Vercel Analytics and Speed Insights to monitor performance, which are privacy-friendly and do not track personal data.", + "learnMore": "Learn more about privacy policy", + "learnMoreAriaLabel": "Learn more about privacy policy and cookie usage", + "acceptAll": "Accept All Cookies", + "essentialOnly": "Essential Only", + "declineAnalytics": "Decline Analytics", + "close": "close" } } diff --git a/messages/es.json b/messages/es.json index 17c5921..2caed7c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -591,5 +591,15 @@ "notSupported": "no soportado", "using": "usando", "serverDetected": "Detectado del servidor" + }, + "CookieBanner": { + "title": "Valoramos tu privacidad", + "description": "¡Hago mis propias cookies! Las preferencias de tema se almacenan localmente en tu navegador. Uso Vercel Analytics y Speed Insights para supervisar el rendimiento, que son respetuosos con la privacidad y no rastrean datos personales.", + "learnMore": "Más información sobre la política de privacidad", + "learnMoreAriaLabel": "Más información sobre la política de privacidad y el uso de cookies", + "acceptAll": "Aceptar todas las cookies", + "essentialOnly": "Solo esenciales", + "declineAnalytics": "Rechazar análisis", + "close": "Cerrar" } } diff --git a/messages/fr.json b/messages/fr.json index b0124c9..9002313 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -591,5 +591,15 @@ "notSupported": "non supporté", "using": "utilise", "serverDetected": "Détecté côté serveur" + }, + "CookieBanner": { + "title": "Nous respectons votre vie privée", + "description": "Je prépare mes propres cookies ! Les préférences de thème sont stockées localement dans votre navigateur. J'utilise Vercel Analytics et Speed Insights pour surveiller les performances, qui respectent la vie privée et ne suivent pas les données personnelles.", + "learnMore": "En savoir plus sur la politique de confidentialité", + "learnMoreAriaLabel": "En savoir plus sur la politique de confidentialité et l'utilisation des cookies", + "acceptAll": "Accepter tous les cookies", + "essentialOnly": "Essentiels uniquement", + "declineAnalytics": "Refuser les analyses", + "close": "Fermer" } } diff --git a/messages/sv.json b/messages/sv.json index e8f0e18..ff8d6c3 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -591,5 +591,15 @@ "notSupported": "stöds ej", "using": "använder", "serverDetected": "Identifierat på serversidan" + }, + "CookieBanner": { + "title": "Vi värdesätter din integritet", + "description": "Jag bakar mina egna kakor! Temainställningar lagras lokalt i din webbläsare. Jag använder Vercel Analytics och Speed Insights för att övervaka prestanda, som är integritetsvänaliga och inte spårar personuppgifter.", + "learnMore": "Läs mer om integritetspolicyn", + "learnMoreAriaLabel": "Läs mer om integritetspolicyn och användning av cookies", + "acceptAll": "Acceptera alla cookies", + "essentialOnly": "Endast nödvändiga", + "declineAnalytics": "Avvisa analys", + "close": "Stäng" } } From 883c2f0a736a3caefc3ba5efb03ed9ae2649a1f4 Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 21 Feb 2026 10:11:15 +0100 Subject: [PATCH 5/6] feat: add force refresh capability and cooldown management to SpeedInsight component --- app/api/speed-insight/route.ts | 14 +++++-- .../speed-insight/SpeedInsight.constants.ts | 3 ++ .../speed-insight/SpeedInsight.logic.ts | 41 +++++++++++++++++-- components/speed-insight/SpeedInsight.tsx | 14 +++++-- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/app/api/speed-insight/route.ts b/app/api/speed-insight/route.ts index c9bcaaa..a5509a7 100644 --- a/app/api/speed-insight/route.ts +++ b/app/api/speed-insight/route.ts @@ -52,6 +52,7 @@ function parseResult( /** Fetch PageSpeed data for a given strategy */ async function fetchPageSpeed( strategy: "mobile" | "desktop", + forceRefresh = false, ): Promise { const params = new URLSearchParams({ url: TARGET_URL, @@ -72,7 +73,9 @@ async function fetchPageSpeed( headers: { Referer: TARGET_URL, }, - next: { revalidate: 3600 }, // Cache for 1 hour + ...(forceRefresh + ? { cache: "no-store" as const } + : { next: { revalidate: 3600 } }), // Cache for 1 hour unless force refresh }); if (!response.ok) { @@ -86,11 +89,14 @@ async function fetchPageSpeed( return parseResult(data, strategy); } -export async function GET(): Promise { +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const forceRefresh = searchParams.has("refresh"); + try { const [desktop, mobile] = await Promise.all([ - fetchPageSpeed("desktop"), - fetchPageSpeed("mobile"), + fetchPageSpeed("desktop", forceRefresh), + fetchPageSpeed("mobile", forceRefresh), ]); const body: SpeedInsightApiResponse = { desktop, mobile }; diff --git a/components/speed-insight/SpeedInsight.constants.ts b/components/speed-insight/SpeedInsight.constants.ts index 8a91a96..ac7da08 100644 --- a/components/speed-insight/SpeedInsight.constants.ts +++ b/components/speed-insight/SpeedInsight.constants.ts @@ -22,3 +22,6 @@ export const SPEED_INSIGHT_API = "/api/speed-insight" as const; /** Cache duration in milliseconds (matches API revalidate: 1 hour) */ export const CACHE_DURATION_MS = 3_600_000 as const; + +/** Minimum cooldown between manual refreshes (2 minutes) */ +export const REFRESH_COOLDOWN_MS = 120_000 as const; diff --git a/components/speed-insight/SpeedInsight.logic.ts b/components/speed-insight/SpeedInsight.logic.ts index 3261854..248c455 100644 --- a/components/speed-insight/SpeedInsight.logic.ts +++ b/components/speed-insight/SpeedInsight.logic.ts @@ -8,7 +8,11 @@ import type { SpeedInsightApiResponse, SpeedInsightResult, } from "@/types/configs/speed-insight"; -import { SPEED_INSIGHT_API, CACHE_DURATION_MS } from "./SpeedInsight.constants"; +import { + SPEED_INSIGHT_API, + CACHE_DURATION_MS, + REFRESH_COOLDOWN_MS, +} from "./SpeedInsight.constants"; /** State shape for the SpeedInsight hook */ interface SpeedInsightState { @@ -21,6 +25,8 @@ interface SpeedInsightState { /** Return type for the useSpeedInsight hook */ interface UseSpeedInsightReturn extends SpeedInsightState { refetch: () => Promise; + /** Seconds remaining until next refresh is allowed (0 = ready) */ + cooldownRemaining: number; } /** Cached response to avoid redundant API calls */ @@ -45,6 +51,9 @@ export function useSpeedInsight(): UseSpeedInsightReturn { }); const abortRef = useRef(null); + const lastRefreshRef = useRef(0); + const [cooldownRemaining, setCooldownRemaining] = useState(0); + const cooldownTimerRef = useRef | null>(null); const fetchData = useCallback(async (force = false): Promise => { // Return cached data if valid and not forced @@ -66,8 +75,13 @@ export function useSpeedInsight(): UseSpeedInsightReturn { setState((prev) => ({ ...prev, loading: true, error: null })); try { - const response = await fetch(SPEED_INSIGHT_API, { + const url = force + ? `${SPEED_INSIGHT_API}?refresh=${Date.now()}` + : SPEED_INSIGHT_API; + + const response = await fetch(url, { signal: controller.signal, + cache: force ? "no-cache" : "default", }); if (!response.ok) { @@ -102,6 +116,26 @@ export function useSpeedInsight(): UseSpeedInsightReturn { }, []); const refetch = useCallback(async (): Promise => { + const elapsed = Date.now() - lastRefreshRef.current; + if (elapsed < REFRESH_COOLDOWN_MS) return; + + lastRefreshRef.current = Date.now(); + setCooldownRemaining(Math.ceil(REFRESH_COOLDOWN_MS / 1000)); + + // Start countdown timer + if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current); + cooldownTimerRef.current = setInterval(() => { + const remaining = Math.ceil( + (REFRESH_COOLDOWN_MS - (Date.now() - lastRefreshRef.current)) / 1000, + ); + if (remaining <= 0) { + setCooldownRemaining(0); + if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current); + } else { + setCooldownRemaining(remaining); + } + }, 1000); + await fetchData(true); }, [fetchData]); @@ -110,8 +144,9 @@ export function useSpeedInsight(): UseSpeedInsightReturn { return () => { abortRef.current?.abort(); + if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current); }; }, [fetchData]); - return { ...state, refetch }; + return { ...state, refetch, cooldownRemaining }; } diff --git a/components/speed-insight/SpeedInsight.tsx b/components/speed-insight/SpeedInsight.tsx index 112d08e..0e6dbfc 100644 --- a/components/speed-insight/SpeedInsight.tsx +++ b/components/speed-insight/SpeedInsight.tsx @@ -107,7 +107,8 @@ function StrategySkeleton() { export default function SpeedInsight({ className }: { className?: string }) { const t = useTranslations("Home.speedInsight"); - const { desktop, mobile, loading, error, refetch } = useSpeedInsight(); + const { desktop, mobile, loading, error, refetch, cooldownRemaining } = + useSpeedInsight(); return (
@@ -193,8 +194,15 @@ export default function SpeedInsight({ className }: { className?: string }) { variant="ghost" size="icon" onClick={() => void refetch()} - disabled={loading} - aria-label={t("retry")} + disabled={loading || cooldownRemaining > 0} + aria-label={ + cooldownRemaining > 0 + ? `${t("retry")} (${cooldownRemaining}s)` + : t("retry") + } + title={ + cooldownRemaining > 0 ? `${cooldownRemaining}s` : undefined + } className="h-8 w-8" > Date: Sat, 21 Feb 2026 10:20:09 +0100 Subject: [PATCH 6/6] chore: update version to 6.0.4 and adjust README formatting --- README.md | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fd02f1a..bf7942d 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ Modern, secure, high‑performance developer portfolio built with Next.js 16, Ty Screenshot 2025-08-31 111906 -- **Live:** https://www.coldbydefault.com -- **Docs:** https://docs.coldbydefault.com/ -- **Stack:** - - Next.js 16 · React 19.2.3 · TypeScript 5.x · Tailwind 4.1.12 · shadcn/ui - - Embla Carousel · Framer Motion 12.x · next-intl 4.6 · Prisma ORM 7 - - Neon PostgreSQL · Zod 4.x · ESLint 9.x · Vercel +1. **Live:** https://www.coldbydefault.com +2. **Docs:** https://docs.coldbydefault.com/ +3. **Stack:** +- Next.js 16 · React 19.2.3 · TypeScript 5.x · Tailwind 4.1.12 · shadcn/ui +- Embla Carousel · Framer Motion 12.x · next-intl 4.6 · Prisma ORM 7 +- Neon PostgreSQL · Zod 4.x · ESLint 9.x · Vercel
diff --git a/package.json b/package.json index 10c9b74..85e63e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coldbydefault-portfolio", - "version": "6.0.3", + "version": "6.0.4", "description": "Professional portfolio of Yazan Abo-Ayash (ColdByDefault™) - Full Stack Developer specializing in AI and automation, Next.js and modern web technologies.", "keywords": [ "portfolio",