From 4046a3c5b3b3ad5190e9a064a63cd3d343e6eb1f Mon Sep 17 00:00:00 2001 From: denshooter Date: Wed, 25 Feb 2026 12:40:47 +0100 Subject: [PATCH 1/3] chore: add ci.yml to dev branch (Node 22, lint/test/build) Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..dce0be2 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Gitea CI + +on: + push: + branches: [main, dev, production] + pull_request: + branches: [main, dev, production] + +jobs: + test-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install deps + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test + + - name: Build + run: npm run build From 6c5297836cdd151520a7b5e17bd71266cba17807 Mon Sep 17 00:00:00 2001 From: denshooter Date: Wed, 25 Feb 2026 12:57:04 +0100 Subject: [PATCH 2/3] fix: randomize quotes, remove CMS idle quote, fix postgres image tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove hardcoded Dennis Konkol idle quote from rotation - Double quote pool (5 → 12 quotes per locale) - Start at a random quote on page load - Cycle to a random non-repeating quote every 10s instead of sequential - Fix dev-deploy.yml: postgres:15-alpine → postgres:16-alpine Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/dev-deploy.yml | 2 +- app/components/About.tsx | 2 +- app/components/ActivityFeed.tsx | 48 +++++++++++++++++++-------------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml index 2f34bdc..bc7904b 100644 --- a/.gitea/workflows/dev-deploy.yml +++ b/.gitea/workflows/dev-deploy.yml @@ -68,7 +68,7 @@ jobs: # Remove old images to force re-pull with correct architecture echo "🔄 Removing old images to force re-pull..." - docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true + docker rmi postgres:16-alpine redis:7-alpine 2>/dev/null || true # Ensure networks exist before compose starts (network is external) echo "🌐 Ensuring networks exist..." diff --git a/app/components/About.tsx b/app/components/About.tsx index adad4ad..4a04a46 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -126,7 +126,7 @@ const About = () => {

Status

- +
diff --git a/app/components/ActivityFeed.tsx b/app/components/ActivityFeed.tsx index 2256a77..770fe77 100644 --- a/app/components/ActivityFeed.tsx +++ b/app/components/ActivityFeed.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -22,14 +22,28 @@ const techQuotes = { { content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" }, { content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" }, { content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" }, - { content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" } + { content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" }, + { content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" }, + { content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" }, + { content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" }, + { content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" }, + { content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" }, + { content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" }, + { content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" }, ], en: [ { content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" }, { content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" }, { content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" }, { content: "Deleted code is debugged code.", author: "Jeff Sickel" }, - { content: "First, solve the problem. Then, write the code.", author: "John Johnson" } + { content: "First, solve the problem. Then, write the code.", author: "John Johnson" }, + { content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" }, + { content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" }, + { content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" }, + { content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" }, + { content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" }, + { content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" }, + { content: "The only way to go fast is to go well.", author: "Robert C. Martin" }, ] }; @@ -39,30 +53,20 @@ function getSafeGamingText(details: string | number | undefined, state: string | return fallback; } -export default function ActivityFeed({ +export default function ActivityFeed({ onActivityChange, - idleQuote, locale = 'en' -}: { +}: { onActivityChange?: (active: boolean) => void; - idleQuote?: string; locale?: string; }) { const [data, setData] = useState(null); const [hasActivity, setHasActivity] = useState(false); - const [quoteIndex, setQuoteIndex] = useState(0); + const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length)); const [loading, setLoading] = useState(true); const t = useTranslations("home.about.activity"); - const currentQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en; - - // Combine CMS quote with tech quotes if available - const allQuotes = React.useMemo(() => { - if (idleQuote) { - return [{ content: idleQuote, author: "Dennis Konkol" }, ...currentQuotes]; - } - return currentQuotes; - }, [idleQuote, currentQuotes]); + const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en; useEffect(() => { const fetchData = async () => { @@ -92,16 +96,20 @@ export default function ActivityFeed({ fetchData(); const statusInterval = setInterval(fetchData, 30000); - // Cycle quotes every 10 seconds + // Pick a random quote every 10 seconds (never the same one twice in a row) const quoteInterval = setInterval(() => { - setQuoteIndex((prev) => (prev + 1) % allQuotes.length); + setQuoteIndex((prev) => { + let next; + do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1); + return next; + }); }, 10000); return () => { clearInterval(statusInterval); clearInterval(quoteInterval); }; - }, [onActivityChange, allQuotes.length]); + }, [onActivityChange]); if (loading) { return
From 32e621df14586a5db2ba01a20c53864295abee1d Mon Sep 17 00:00:00 2001 From: denshooter Date: Fri, 27 Feb 2026 23:12:50 +0100 Subject: [PATCH 3/3] fix: namespace rate limit buckets per endpoint, remove custom analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `prefix` param to checkRateLimit/getRateLimitHeaders so each endpoint has its own bucket (previously all shared `admin_${ip}`, causing 429s when analytics/track incremented past n8n endpoints' lower limits) - n8n/hardcover/currently-reading → prefix 'n8n-reading' - n8n/status → prefix 'n8n-status' - analytics/track → prefix 'analytics-track' - Remove custom analytics system (AnalyticsProvider, lib/analytics, lib/useWebVitals, all /api/analytics/* routes) — was causing 500s in production due to missing PostgreSQL PageView table - Remove analytics consent toggle from ConsentBanner/ConsentProvider Co-Authored-By: Claude Sonnet 4.6 --- app/_ui/ProjectDetailClient.tsx | 8 +- app/api/analytics/dashboard/route.ts | 174 --------- app/api/analytics/performance/route.ts | 139 ------- app/api/analytics/reset/route.ts | 211 ----------- app/api/analytics/route.ts | 51 --- app/api/analytics/track/route.ts | 187 --------- .../n8n/hardcover/currently-reading/route.ts | 2 +- app/api/n8n/status/route.ts | 2 +- app/components/ClientProviders.tsx | 12 +- app/components/ConsentBanner.tsx | 17 +- app/components/ConsentProvider.tsx | 2 - app/components/RootProviders.tsx | 20 +- app/projects/[slug]/page.tsx | 19 - components/AnalyticsProvider.tsx | 321 ---------------- lib/analytics.ts | 144 ------- lib/auth.ts | 8 +- lib/useWebVitals.ts | 355 ------------------ 17 files changed, 17 insertions(+), 1655 deletions(-) delete mode 100644 app/api/analytics/dashboard/route.ts delete mode 100644 app/api/analytics/performance/route.ts delete mode 100644 app/api/analytics/reset/route.ts delete mode 100644 app/api/analytics/route.ts delete mode 100644 app/api/analytics/track/route.ts delete mode 100644 components/AnalyticsProvider.tsx delete mode 100644 lib/analytics.ts delete mode 100644 lib/useWebVitals.ts diff --git a/app/_ui/ProjectDetailClient.tsx b/app/_ui/ProjectDetailClient.tsx index 4b206c3..d642379 100644 --- a/app/_ui/ProjectDetailClient.tsx +++ b/app/_ui/ProjectDetailClient.tsx @@ -46,13 +46,7 @@ export default function ProjectDetailClient({ setCanGoBack(true); } - try { - navigator.sendBeacon?.( - "/api/analytics/track", - new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }), - ); - } catch {} - }, [project.id, project.slug, locale]); + }, []); const handleBack = (e: React.MouseEvent) => { e.preventDefault(); diff --git a/app/api/analytics/dashboard/route.ts b/app/api/analytics/dashboard/route.ts deleted file mode 100644 index 497949e..0000000 --- a/app/api/analytics/dashboard/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma, projectService } from '@/lib/prisma'; -import { analyticsCache } from '@/lib/redis'; -import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; - -export async function GET(request: NextRequest) { - try { - // Rate limiting - more generous for admin dashboard - const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute - return new NextResponse( - JSON.stringify({ error: 'Rate limit exceeded' }), - { - status: 429, - headers: { - 'Content-Type': 'application/json', - ...getRateLimitHeaders(ip, 20, 60000) - } - } - ); - } - - // Admin-only endpoint: require explicit admin header AND a valid signed session token - const isAdminRequest = request.headers.get('x-admin-request') === 'true'; - if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); - const authError = requireSessionAuth(request); - if (authError) return authError; - - // Check cache first (but allow bypass with cache-bust parameter) - const url = new URL(request.url); - const bypassCache = url.searchParams.get('nocache') === 'true'; - - if (!bypassCache) { - const cachedStats = await analyticsCache.getOverallStats(); - if (cachedStats) { - return NextResponse.json(cachedStats); - } - } - - // Get analytics data - const projectsResult = await projectService.getAllProjects(); - const projects = projectsResult.projects || projectsResult; - const performanceStats = await projectService.getPerformanceStats(); - - const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - // Use DB aggregation instead of loading every PageView row into memory - const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([ - prisma.pageView.count({ where: { timestamp: { gte: since } } }), - prisma.pageView.groupBy({ - by: ['ip'], - where: { - timestamp: { gte: since }, - ip: { not: null }, - }, - _count: { _all: true }, - _min: { timestamp: true }, - _max: { timestamp: true }, - }), - prisma.pageView.groupBy({ - by: ['projectId'], - where: { - timestamp: { gte: since }, - projectId: { not: null }, - }, - _count: { _all: true }, - }), - ]); - - const totalSessions = sessionsByIp.length; - const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length; - const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0; - - const sessionDurationsMs = sessionsByIp - .map(s => { - const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0; - if (count < 2) return 0; - const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp; - const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp; - if (!minTs || !maxTs) return 0; - return maxTs.getTime() - minTs.getTime(); - }) - .filter(ms => ms > 0); - - const avgSessionDuration = sessionDurationsMs.length > 0 - ? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000) - : 0; - - const totalUsers = totalSessions; - - const viewsByProject = viewsByProjectRows.reduce((acc, row) => { - const projectId = row.projectId as number | null; - if (projectId != null) { - acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0; - } - return acc; - }, {} as Record); - - // Calculate analytics metrics - const analytics = { - overview: { - totalProjects: projects.length, - publishedProjects: projects.filter(p => p.published).length, - featuredProjects: projects.filter(p => p.featured).length, - totalViews, // Real views from PageView table - totalLikes: 0, // Not implemented - no like buttons - totalShares: 0, // Not implemented - no share buttons - avgLighthouse: (() => { - // Only calculate if we have real performance data (not defaults) - const projectsWithPerf = projects.filter(p => { - const perf = (p.performance as Record) || {}; - const lighthouse = perf.lighthouse as number || 0; - return lighthouse > 0; // Only count projects with actual performance data - }); - return projectsWithPerf.length > 0 - ? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record)?.lighthouse as number || 0), 0) / projectsWithPerf.length) - : 0; - })() - }, - projects: projects.map(project => ({ - id: project.id, - title: project.title, - category: project.category, - difficulty: project.difficulty, - views: viewsByProject[project.id] || 0, // Only real views from PageView table - likes: 0, // Not implemented - shares: 0, // Not implemented - lighthouse: (() => { - const perf = (project.performance as Record) || {}; - const score = perf.lighthouse as number || 0; - return score > 0 ? score : 0; // Only return if we have real data - })(), - published: project.published, - featured: project.featured, - createdAt: project.createdAt, - updatedAt: project.updatedAt - })), - categories: performanceStats.byCategory, - difficulties: performanceStats.byDifficulty, - performance: { - avgLighthouse: (() => { - const projectsWithPerf = projects.filter(p => { - const perf = (p.performance as Record) || {}; - return (perf.lighthouse as number || 0) > 0; - }); - return projectsWithPerf.length > 0 - ? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record)?.lighthouse as number || 0), 0) / projectsWithPerf.length) - : 0; - })(), - totalViews, // Real total views - totalLikes: 0, - totalShares: 0 - }, - metrics: { - bounceRate, - avgSessionDuration, - pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0', - newUsers: totalUsers, - totalUsers - } - }; - - // Cache the results - await analyticsCache.setOverallStats(analytics); - - return NextResponse.json(analytics); - } catch (error) { - console.error('Analytics dashboard error:', error); - return NextResponse.json( - { error: 'Failed to fetch analytics data' }, - { status: 500 } - ); - } -} diff --git a/app/api/analytics/performance/route.ts b/app/api/analytics/performance/route.ts deleted file mode 100644 index 697bf1c..0000000 --- a/app/api/analytics/performance/route.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { requireSessionAuth } from '@/lib/auth'; - -export async function GET(request: NextRequest) { - try { - // Admin-only endpoint - const isAdminRequest = request.headers.get('x-admin-request') === 'true'; - if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); - const authError = requireSessionAuth(request); - if (authError) return authError; - - // Get performance data from database - const pageViews = await prisma.pageView.findMany({ - orderBy: { timestamp: 'desc' }, - take: 1000 // Last 1000 page views - }); - - const userInteractions = await prisma.userInteraction.findMany({ - orderBy: { timestamp: 'desc' }, - take: 1000 // Last 1000 interactions - }); - - // Get all projects for performance data - const projects = await prisma.project.findMany(); - - // Calculate real performance metrics from projects - const projectsWithPerformance = projects.map(p => ({ - id: p.id, - title: p.title, - lighthouse: ((p.performance as Record)?.lighthouse as number) || 0, - loadTime: ((p.performance as Record)?.loadTime as number) || 0, - fcp: ((p.performance as Record)?.firstContentfulPaint as number) || 0, - lcp: ((p.performance as Record)?.coreWebVitals as Record)?.lcp as number || 0, - cls: ((p.performance as Record)?.coreWebVitals as Record)?.cls as number || 0 - })); - - // Calculate average lighthouse score (currently unused but kept for future use) - const _avgLighthouse = projectsWithPerformance.length > 0 - ? Math.round(projectsWithPerformance.reduce((sum, p) => sum + p.lighthouse, 0) / projectsWithPerformance.length) - : 0; - - // Calculate bounce rate from page views - const pageViewsByIP = pageViews.reduce((acc, pv) => { - const ip = pv.ip || 'unknown'; - if (!acc[ip]) acc[ip] = []; - acc[ip].push(pv); - return acc; - }, {} as Record); - - const totalSessions = Object.keys(pageViewsByIP).length; - const bouncedSessions = Object.values(pageViewsByIP).filter(session => session.length === 1).length; - const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0; - - // Calculate average session duration - const sessionDurations = Object.values(pageViewsByIP) - .map(session => { - if (session.length < 2) return 0; - const sorted = session.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - return sorted[sorted.length - 1].timestamp.getTime() - sorted[0].timestamp.getTime(); - }) - .filter(d => d > 0); - const avgSessionDuration = sessionDurations.length > 0 - ? Math.round(sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length / 1000) // in seconds - : 0; - - // Calculate pages per session - const pagesPerSession = totalSessions > 0 ? (pageViews.length / totalSessions).toFixed(1) : '0'; - - // Calculate performance metrics - const performance = { - avgLighthouse: (() => { - const projectsWithPerf = projects.filter(p => { - const perf = (p.performance as Record) || {}; - return (perf.lighthouse as number || 0) > 0; - }); - return projectsWithPerf.length > 0 - ? Math.round(projectsWithPerf.reduce((sum, p) => { - const perf = (p.performance as Record) || {}; - return sum + (perf.lighthouse as number || 0); - }, 0) / projectsWithPerf.length) - : 0; - })(), - totalViews: pageViews.length, - metrics: { - bounceRate, - avgSessionDuration: avgSessionDuration, - pagesPerSession: parseFloat(pagesPerSession), - newUsers: new Set(pageViews.map(pv => pv.ip).filter(Boolean)).size - }, - pageViews: { - total: pageViews.length, - last24h: pageViews.filter(pv => { - const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - return new Date(pv.timestamp) > dayAgo; - }).length, - last7d: pageViews.filter(pv => { - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - return new Date(pv.timestamp) > weekAgo; - }).length, - last30d: pageViews.filter(pv => { - const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - return new Date(pv.timestamp) > monthAgo; - }).length - }, - interactions: { - total: userInteractions.length, - last24h: userInteractions.filter(ui => { - const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - return new Date(ui.timestamp) > dayAgo; - }).length, - last7d: userInteractions.filter(ui => { - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - return new Date(ui.timestamp) > weekAgo; - }).length, - last30d: userInteractions.filter(ui => { - const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - return new Date(ui.timestamp) > monthAgo; - }).length - }, - topPages: pageViews.reduce((acc, pv) => { - acc[pv.page] = (acc[pv.page] || 0) + 1; - return acc; - }, {} as Record), - topInteractions: userInteractions.reduce((acc, ui) => { - acc[ui.type] = (acc[ui.type] || 0) + 1; - return acc; - }, {} as Record) - }; - - return NextResponse.json(performance); - } catch (error) { - console.error('Performance analytics error:', error); - return NextResponse.json( - { error: 'Failed to fetch performance data' }, - { status: 500 } - ); - } -} diff --git a/app/api/analytics/reset/route.ts b/app/api/analytics/reset/route.ts deleted file mode 100644 index f99e428..0000000 --- a/app/api/analytics/reset/route.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { analyticsCache } from '@/lib/redis'; -import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - if (!checkRateLimit(ip, 3, 300000)) { // 3 requests per 5 minutes - more restrictive for reset - return new NextResponse( - JSON.stringify({ error: 'Rate limit exceeded' }), - { - status: 429, - headers: { - 'Content-Type': 'application/json', - ...getRateLimitHeaders(ip, 3, 300000) - } - } - ); - } - - // Check admin authentication - const isAdminRequest = request.headers.get('x-admin-request') === 'true'; - if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); - const authError = requireSessionAuth(request); - if (authError) return authError; - - const { type } = await request.json(); - - switch (type) { - case 'analytics': - // Reset all project analytics (view counts in project.analytics JSON) - const projects = await prisma.project.findMany(); - for (const project of projects) { - const analytics = (project.analytics as Record) || {}; - await prisma.project.update({ - where: { id: project.id }, - data: { - analytics: { - ...analytics, - views: 0, - likes: 0, - shares: 0, - comments: 0, - bookmarks: 0, - clickThroughs: 0, - bounceRate: 0, - avgTimeOnPage: 0, - uniqueVisitors: 0, - returningVisitors: 0, - conversionRate: 0, - socialShares: { - twitter: 0, - linkedin: 0, - facebook: 0, - github: 0 - }, - deviceStats: { - mobile: 0, - desktop: 0, - tablet: 0 - }, - locationStats: {}, - referrerStats: {}, - lastUpdated: new Date().toISOString() - } - } - }); - } - break; - - case 'pageviews': - // Clear PageView table - await prisma.pageView.deleteMany({}); - break; - - case 'interactions': - // Clear UserInteraction table - await prisma.userInteraction.deleteMany({}); - break; - - case 'performance': - // Reset performance metrics (preserve structure) - const projectsForPerf = await prisma.project.findMany(); - for (const project of projectsForPerf) { - const perf = (project.performance as Record) || {}; - await prisma.project.update({ - where: { id: project.id }, - data: { - performance: { - ...perf, - lighthouse: 0, - loadTime: 0, - firstContentfulPaint: 0, - largestContentfulPaint: 0, - cumulativeLayoutShift: 0, - totalBlockingTime: 0, - speedIndex: 0, - accessibility: 0, - bestPractices: 0, - seo: 0, - performanceScore: 0, - mobileScore: 0, - desktopScore: 0, - coreWebVitals: { - lcp: 0, - fid: 0, - cls: 0 - }, - lastUpdated: new Date().toISOString() - } - } - }); - } - break; - - case 'all': - // Reset everything - const allProjects = await prisma.project.findMany(); - await Promise.all([ - // Reset analytics and performance for each project (preserve structure) - ...allProjects.map(project => { - const analytics = (project.analytics as Record) || {}; - const perf = (project.performance as Record) || {}; - return prisma.project.update({ - where: { id: project.id }, - data: { - analytics: { - ...analytics, - views: 0, - likes: 0, - shares: 0, - comments: 0, - bookmarks: 0, - clickThroughs: 0, - bounceRate: 0, - avgTimeOnPage: 0, - uniqueVisitors: 0, - returningVisitors: 0, - conversionRate: 0, - socialShares: { - twitter: 0, - linkedin: 0, - facebook: 0, - github: 0 - }, - deviceStats: { - mobile: 0, - desktop: 0, - tablet: 0 - }, - locationStats: {}, - referrerStats: {}, - lastUpdated: new Date().toISOString() - }, - performance: { - ...perf, - lighthouse: 0, - loadTime: 0, - firstContentfulPaint: 0, - largestContentfulPaint: 0, - cumulativeLayoutShift: 0, - totalBlockingTime: 0, - speedIndex: 0, - accessibility: 0, - bestPractices: 0, - seo: 0, - performanceScore: 0, - mobileScore: 0, - desktopScore: 0, - coreWebVitals: { - lcp: 0, - fid: 0, - cls: 0 - }, - lastUpdated: new Date().toISOString() - } - } - }); - }), - // Clear tracking tables - prisma.pageView.deleteMany({}), - prisma.userInteraction.deleteMany({}) - ]); - break; - - default: - return NextResponse.json( - { error: 'Invalid reset type. Use: analytics, pageviews, interactions, performance, or all' }, - { status: 400 } - ); - } - - // Clear cache - await analyticsCache.clearAll(); - - return NextResponse.json({ - success: true, - message: `Successfully reset ${type} data`, - timestamp: new Date().toISOString() - }); - - } catch (error) { - console.error('Analytics reset error:', error); - return NextResponse.json( - { error: 'Failed to reset analytics data' }, - { status: 500 } - ); - } -} diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts deleted file mode 100644 index 650f4a6..0000000 --- a/app/api/analytics/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; - -export async function POST(request: NextRequest) { - try { - // Rate limiting for POST requests - const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for analytics - return new NextResponse( - JSON.stringify({ error: 'Rate limit exceeded' }), - { - status: 429, - headers: { - 'Content-Type': 'application/json', - ...getRateLimitHeaders(ip, 30, 60000) - } - } - ); - } - - const body = await request.json(); - - // Log performance metrics (you can extend this to store in database) - if (process.env.NODE_ENV === 'development') { - console.log('Performance Metric:', { - timestamp: new Date().toISOString(), - ...body, - }); - } - - // You could store this in a database or send to external service - // For now, we'll just log it since Umami handles the main analytics - - return NextResponse.json({ success: true }); - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.error('Analytics API Error:', error); - } - return NextResponse.json( - { error: 'Failed to process analytics data' }, - { status: 500 } - ); - } -} - -export async function GET() { - return NextResponse.json({ - message: 'Analytics API is running', - timestamp: new Date().toISOString(), - }); -} diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts deleted file mode 100644 index 2291145..0000000 --- a/app/api/analytics/track/route.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - if (!checkRateLimit(ip, 100, 60000)) { // 100 requests per minute for tracking - return new NextResponse( - JSON.stringify({ error: 'Rate limit exceeded' }), - { - status: 429, - headers: { - 'Content-Type': 'application/json', - ...getRateLimitHeaders(ip, 100, 60000) - } - } - ); - } - - const body = await request.json(); - const { type, projectId, page, performance, session } = body; - const userAgent = request.headers.get('user-agent') || undefined; - const referrer = request.headers.get('referer') || undefined; - - // Track page view - if (type === 'pageview' && page) { - let projectIdNum: number | null = null; - if (projectId != null) { - const raw = projectId.toString(); - const parsed = parseInt(raw, 10); - if (Number.isFinite(parsed)) { - projectIdNum = parsed; - } else { - const bySlug = await prisma.project.findFirst({ - where: { slug: raw }, - select: { id: true }, - }); - projectIdNum = bySlug?.id ?? null; - } - } - - // Create page view record - await prisma.pageView.create({ - data: { - projectId: projectIdNum, - page, - ip, - userAgent, - referrer - } - }); - - // Update project analytics if projectId exists - if (projectIdNum) { - const project = await prisma.project.findUnique({ - where: { id: projectIdNum } - }); - - if (project) { - const analytics = (project.analytics as Record) || {}; - const currentViews = (analytics.views as number) || 0; - - await prisma.project.update({ - where: { id: projectIdNum }, - data: { - analytics: { - ...analytics, - views: currentViews + 1, - lastUpdated: new Date().toISOString() - } - } - }); - } - } - } - - // Track performance metrics - if (type === 'performance' && performance) { - // Try to get projectId from page path if not provided - let projectIdNum: number | null = null; - if (projectId) { - projectIdNum = parseInt(projectId.toString()); - } else if (page) { - // Try to extract from page path like /projects/123 or /projects/slug - const match = page.match(/\/projects\/(\d+)/); - if (match) { - projectIdNum = parseInt(match[1]); - } else { - // Try to find by slug - const slugMatch = page.match(/\/projects\/([^\/]+)/); - if (slugMatch) { - const slug = slugMatch[1]; - const project = await prisma.project.findFirst({ - where: { - OR: [ - { id: parseInt(slug) || 0 }, - { slug } - ] - } - }); - if (project) projectIdNum = project.id; - } - } - } - - if (projectIdNum) { - const project = await prisma.project.findUnique({ - where: { id: projectIdNum } - }); - - if (project) { - const perf = (project.performance as Record) || {}; - const analytics = (project.analytics as Record) || {}; - - // Calculate lighthouse score from web vitals - const lcp = performance.lcp || 0; - const fid = performance.fid || 0; - const cls = performance.cls || 0; - const fcp = performance.fcp || 0; - const ttfb = performance.ttfb || 0; - - // Only calculate lighthouse score if we have real web vitals data - // Check if we have at least LCP and FCP (most important metrics) - if (lcp > 0 || fcp > 0) { - // Simple lighthouse score calculation (0-100) - let lighthouseScore = 100; - if (lcp > 4000) lighthouseScore -= 25; - else if (lcp > 2500) lighthouseScore -= 15; - if (fid > 300) lighthouseScore -= 25; - else if (fid > 100) lighthouseScore -= 15; - if (cls > 0.25) lighthouseScore -= 25; - else if (cls > 0.1) lighthouseScore -= 15; - if (fcp > 3000) lighthouseScore -= 15; - if (ttfb > 800) lighthouseScore -= 10; - - lighthouseScore = Math.max(0, Math.min(100, lighthouseScore)); - - await prisma.project.update({ - where: { id: projectIdNum }, - data: { - performance: { - ...perf, - lighthouse: lighthouseScore, - loadTime: performance.loadTime || perf.loadTime || 0, - firstContentfulPaint: fcp || perf.firstContentfulPaint || 0, - largestContentfulPaint: lcp || perf.largestContentfulPaint || 0, - cumulativeLayoutShift: cls || perf.cumulativeLayoutShift || 0, - totalBlockingTime: performance.tbt || perf.totalBlockingTime || 0, - speedIndex: performance.si || perf.speedIndex || 0, - coreWebVitals: { - lcp: lcp || (perf.coreWebVitals as Record)?.lcp || 0, - fid: fid || (perf.coreWebVitals as Record)?.fid || 0, - cls: cls || (perf.coreWebVitals as Record)?.cls || 0 - }, - lastUpdated: new Date().toISOString() - }, - analytics: { - ...analytics, - lastUpdated: new Date().toISOString() - } - } - }); - } - } - } - } - - // Track session data (for bounce rate calculation) - if (type === 'session' && session) { - // Store session data in a way that allows bounce rate calculation - // A bounce is a session with only one pageview - // We'll track this via PageView records and calculate bounce rate from them - } - - return NextResponse.json({ success: true }); - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.error('Analytics tracking error:', error); - } - return NextResponse.json( - { error: 'Failed to track analytics' }, - { status: 500 } - ); - } -} diff --git a/app/api/n8n/hardcover/currently-reading/route.ts b/app/api/n8n/hardcover/currently-reading/route.ts index 0d4d949..5bec57f 100644 --- a/app/api/n8n/hardcover/currently-reading/route.ts +++ b/app/api/n8n/hardcover/currently-reading/route.ts @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { : ip; const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10; - if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute + if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute return NextResponse.json( { error: 'Rate limit exceeded. Please try again later.' }, { status: 429 } diff --git a/app/api/n8n/status/route.ts b/app/api/n8n/status/route.ts index 83b1466..2734cb6 100644 --- a/app/api/n8n/status/route.ts +++ b/app/api/n8n/status/route.ts @@ -20,7 +20,7 @@ export async function GET(request: NextRequest) { : ip; const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30; - if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute + if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute return NextResponse.json( { error: 'Rate limit exceeded. Please try again later.' }, { status: 429 } diff --git a/app/components/ClientProviders.tsx b/app/components/ClientProviders.tsx index c8031aa..06efe5a 100644 --- a/app/components/ClientProviders.tsx +++ b/app/components/ClientProviders.tsx @@ -5,8 +5,7 @@ import { usePathname } from "next/navigation"; import dynamic from "next/dynamic"; import { ToastProvider } from "@/components/Toast"; import ErrorBoundary from "@/components/ErrorBoundary"; -import { AnalyticsProvider } from "@/components/AnalyticsProvider"; -import { ConsentProvider, useConsent } from "./ConsentProvider"; +import { ConsentProvider } from "./ConsentProvider"; import { ThemeProvider } from "./ThemeProvider"; import { motion, AnimatePresence } from "framer-motion"; @@ -97,12 +96,7 @@ function GatedProviders({ mounted: boolean; is404Page: boolean; }) { - const { consent } = useConsent(); - - // If consent is not decided yet, treat optional features as off - const analyticsEnabled = !!consent?.analytics; - - const content = ( + return ( {mounted && } @@ -110,6 +104,4 @@ function GatedProviders({ ); - - return analyticsEnabled ? {content} : content; } diff --git a/app/components/ConsentBanner.tsx b/app/components/ConsentBanner.tsx index 8f5f56b..22f09dd 100644 --- a/app/components/ConsentBanner.tsx +++ b/app/components/ConsentBanner.tsx @@ -6,7 +6,7 @@ import { useTranslations } from "next-intl"; export default function ConsentBanner() { const { consent, ready, setConsent } = useConsent(); - const [draft, setDraft] = useState({ analytics: false, chat: false }); + const [draft, setDraft] = useState({ chat: false }); const [minimized, setMinimized] = useState(false); const t = useTranslations("consent"); @@ -19,7 +19,6 @@ export default function ConsentBanner() { title: t("title"), description: t("description"), essential: t("essential"), - analytics: t("analytics"), chat: t("chat"), alwaysOn: t("alwaysOn"), acceptAll: t("acceptAll"), @@ -68,16 +67,6 @@ export default function ConsentBanner() {
{s.alwaysOn}
- -
}> - - - -
{children}
- -
-
+ + +
{children}
+ +
); } - diff --git a/app/projects/[slug]/page.tsx b/app/projects/[slug]/page.tsx index 0db386d..d9e6862 100644 --- a/app/projects/[slug]/page.tsx +++ b/app/projects/[slug]/page.tsx @@ -41,25 +41,6 @@ const ProjectDetail = () => { const loadedProject = data.projects[0]; setProject(loadedProject); - // Track page view - try { - await fetch('/api/analytics/track', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: 'pageview', - projectId: loadedProject.id.toString(), - page: `/projects/${slug}` - }) - }); - } catch (trackError) { - // Silently fail tracking - if (process.env.NODE_ENV === 'development') { - console.error('Error tracking page view:', trackError); - } - } } } } catch (error) { diff --git a/components/AnalyticsProvider.tsx b/components/AnalyticsProvider.tsx deleted file mode 100644 index 20bd7e2..0000000 --- a/components/AnalyticsProvider.tsx +++ /dev/null @@ -1,321 +0,0 @@ -'use client'; - -import { useEffect, useRef, useCallback } from 'react'; -import { useWebVitals } from '@/lib/useWebVitals'; -import { trackEvent, trackPageLoad } from '@/lib/analytics'; -import { debounce } from '@/lib/utils'; - -interface AnalyticsProviderProps { - children: React.ReactNode; -} - -export const AnalyticsProvider: React.FC = ({ children }) => { - const hasTrackedInitialView = useRef(false); - const hasTrackedPerformance = useRef(false); - const currentPath = useRef(''); - - // Initialize Web Vitals tracking - wrapped to prevent crashes - // Hooks must be called unconditionally, but the hook itself handles errors - useWebVitals(); - - // Track page view - memoized to prevent recreation - const trackPageView = useCallback(async () => { - if (typeof window === 'undefined') return; - - const path = window.location.pathname; - - // Only track if path has changed (prevents duplicate tracking) - if (currentPath.current === path && hasTrackedInitialView.current) { - return; - } - - currentPath.current = path; - hasTrackedInitialView.current = true; - - const projectMatch = path.match(/\/projects\/([^\/]+)/); - const projectId = projectMatch ? projectMatch[1] : null; - - // Track to Umami (if available) - trackEvent('page-view', { - url: path, - referrer: document.referrer, - timestamp: Date.now(), - }); - - // Track to our API - single call - try { - await fetch('/api/analytics/track', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: 'pageview', - projectId: projectId, - page: path - }) - }); - } catch (error) { - // Silently fail - if (process.env.NODE_ENV === 'development') { - console.error('Error tracking page view:', error); - } - } - }, []); - - useEffect(() => { - if (typeof window === 'undefined') return; - - // Wrap entire effect in try-catch to prevent any errors from breaking the app - try { - // Track page load performance - wrapped in try-catch - try { - trackPageLoad(); - } catch (error) { - // Silently fail - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking page load:', error); - } - } - - // Track initial page view - trackPageView(); - - // Track performance metrics to our API - only once - const trackPerformanceToAPI = async () => { - // Prevent duplicate tracking - if (hasTrackedPerformance.current) return; - hasTrackedPerformance.current = true; - - try { - if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") { - return; - } - - // Get current page path to extract project ID if on project page - const path = window.location.pathname; - const projectMatch = path.match(/\/projects\/([^\/]+)/); - const projectId = projectMatch ? projectMatch[1] : null; - - // Wait for page to fully load - setTimeout(async () => { - try { - const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined; - const paintEntries = performance.getEntriesByType('paint'); - const lcpEntries = performance.getEntriesByType('largest-contentful-paint'); - - const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint'); - const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined; - - const performanceData = { - loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0, - fcp: fcp ? fcp.startTime : 0, - lcp: lcp ? lcp.startTime : 0, - ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0, - cls: 0, // Will be updated by CLS observer - fid: 0, // Will be updated by FID observer - si: 0 // Speed Index - would need to calculate - }; - - // Send performance data - single call - await fetch('/api/analytics/track', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: 'performance', - projectId: projectId, - page: path, - performance: performanceData - }) - }); - } catch (error) { - // Silently fail - performance tracking is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('Error collecting performance data:', error); - } - } - }, 2500); // Wait 2.5 seconds for page to stabilize - } catch (error) { - // Silently fail - if (process.env.NODE_ENV === 'development') { - console.error('Error tracking performance:', error); - } - } - }; - - // Track performance after page load - if (document.readyState === 'complete') { - trackPerformanceToAPI(); - } else { - window.addEventListener('load', trackPerformanceToAPI, { once: true }); - } - - // Track route changes (for SPA navigation) - debounced - const handleRouteChange = debounce(() => { - // Track new page view (trackPageView will handle path change detection) - trackPageView(); - trackPageLoad(); - }, 300); - - // Listen for popstate events (back/forward navigation) - window.addEventListener('popstate', handleRouteChange); - - // Track user interactions - debounced to prevent spam - const handleClick = debounce((event: unknown) => { - try { - if (typeof window === 'undefined') return; - - const mouseEvent = event as MouseEvent; - const target = mouseEvent.target as HTMLElement | null; - if (!target) return; - - const element = target.tagName ? target.tagName.toLowerCase() : 'unknown'; - const className = target.className; - const id = target.id; - - trackEvent('click', { - element, - className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined, - id: id || undefined, - url: window.location.pathname, - }); - } catch (error) { - // Silently fail - click tracking is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking click:', error); - } - } - }, 500); - - // Track form submissions - const handleSubmit = (event: SubmitEvent) => { - try { - if (typeof window === 'undefined') return; - - const form = event.target as HTMLFormElement | null; - if (!form) return; - - trackEvent('form-submit', { - formId: form.id || undefined, - formClass: form.className || undefined, - url: window.location.pathname, - }); - } catch (error) { - // Silently fail - form tracking is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking form submit:', error); - } - } - }; - - // Track scroll depth - debounced - let maxScrollDepth = 0; - const firedScrollMilestones = new Set(); - const handleScroll = debounce(() => { - try { - if (typeof window === 'undefined' || typeof document === 'undefined') return; - - const scrollHeight = document.documentElement.scrollHeight; - const innerHeight = window.innerHeight; - - if (scrollHeight <= innerHeight) return; // No scrollable content - - const scrollDepth = Math.round( - (window.scrollY / (scrollHeight - innerHeight)) * 100 - ); - - if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth; - - // Track each milestone once (avoid spamming events on every scroll tick) - const milestones = [25, 50, 75, 90]; - for (const milestone of milestones) { - if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) { - firedScrollMilestones.add(milestone); - trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname }); - } - } - } catch (error) { - // Silently fail - scroll tracking is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking scroll:', error); - } - } - }, 1000); - - // Add event listeners - document.addEventListener('click', handleClick); - document.addEventListener('submit', handleSubmit); - window.addEventListener('scroll', handleScroll, { passive: true }); - - // Track errors - const handleError = (event: ErrorEvent) => { - try { - if (typeof window === 'undefined') return; - trackEvent('error', { - message: event.message || 'Unknown error', - filename: event.filename || undefined, - lineno: event.lineno || undefined, - colno: event.colno || undefined, - url: window.location.pathname, - }); - } catch (error) { - // Silently fail - error tracking should not cause more errors - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking error event:', error); - } - } - }; - - const handleUnhandledRejection = (event: PromiseRejectionEvent) => { - try { - if (typeof window === 'undefined') return; - trackEvent('unhandled-rejection', { - reason: event.reason?.toString() || 'Unknown rejection', - url: window.location.pathname, - }); - } catch (error) { - // Silently fail - error tracking should not cause more errors - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking unhandled rejection:', error); - } - } - }; - - window.addEventListener('error', handleError); - window.addEventListener('unhandledrejection', handleUnhandledRejection); - - // Cleanup - return () => { - try { - // Cancel any pending debounced calls to prevent memory leaks - handleRouteChange.cancel(); - handleClick.cancel(); - handleScroll.cancel(); - - // Remove event listeners - window.removeEventListener('load', trackPerformanceToAPI); - window.removeEventListener('popstate', handleRouteChange); - document.removeEventListener('click', handleClick); - document.removeEventListener('submit', handleSubmit); - window.removeEventListener('scroll', handleScroll); - window.removeEventListener('error', handleError); - window.removeEventListener('unhandledrejection', handleUnhandledRejection); - } catch { - // Silently fail during cleanup - } - }; - } catch (error) { - // If anything fails, log but don't break the app - if (process.env.NODE_ENV === 'development') { - console.error('AnalyticsProvider initialization error:', error); - } - // Return empty cleanup function - return () => {}; - } - }, [trackPageView]); - - // Always render children, even if analytics fails - return <>{children}; -}; diff --git a/lib/analytics.ts b/lib/analytics.ts deleted file mode 100644 index 35eba8c..0000000 --- a/lib/analytics.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Analytics utilities for Umami with Performance Tracking -declare global { - interface Window { - umami?: { - track: (event: string, data?: Record) => void; - }; - } -} - -export interface PerformanceMetric { - name: string; - value: number; - url: string; - timestamp: number; - userAgent?: string; -} - -export interface WebVitalsMetric { - name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB'; - value: number; - delta: number; - id: string; - url: string; -} - -// Track custom events to Umami -export const trackEvent = (event: string, data?: Record) => { - if (typeof window === "undefined") return; - const trackFn = window.umami?.track; - if (typeof trackFn !== "function") return; - - try { - trackFn(event, { - ...data, - timestamp: Date.now(), - url: window.location.pathname, - }); - } catch (error) { - // Silently fail - analytics must never break the app - if (process.env.NODE_ENV === "development") { - console.warn("Error tracking Umami event:", error); - } - } -}; - -// Track performance metrics -export const trackPerformance = (metric: PerformanceMetric) => { - trackEvent('performance', { - metric: metric.name, - value: Math.round(metric.value), - url: metric.url, - userAgent: metric.userAgent, - }); -}; - -// Track Web Vitals -export const trackWebVitals = (metric: WebVitalsMetric) => { - trackEvent('web-vitals', { - name: metric.name, - value: Math.round(metric.value), - delta: Math.round(metric.delta), - id: metric.id, - url: metric.url, - }); -}; - -// Track page load performance -export const trackPageLoad = () => { - if (typeof window === 'undefined' || typeof performance === 'undefined') return; - - try { - const navigationEntries = performance.getEntriesByType('navigation'); - const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined; - - if (navigation && navigation.loadEventEnd && navigation.fetchStart) { - trackPerformance({ - name: 'page-load', - value: navigation.loadEventEnd - navigation.fetchStart, - url: window.location.pathname, - timestamp: Date.now(), - userAgent: navigator.userAgent, - }); - - // Track individual timing phases - trackEvent('page-timing', { - dns: navigation.domainLookupEnd && navigation.domainLookupStart - ? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart) - : 0, - tcp: navigation.connectEnd && navigation.connectStart - ? Math.round(navigation.connectEnd - navigation.connectStart) - : 0, - request: navigation.responseStart && navigation.requestStart - ? Math.round(navigation.responseStart - navigation.requestStart) - : 0, - response: navigation.responseEnd && navigation.responseStart - ? Math.round(navigation.responseEnd - navigation.responseStart) - : 0, - dom: navigation.domContentLoadedEventEnd && navigation.responseEnd - ? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd) - : 0, - load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd - ? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd) - : 0, - url: window.location.pathname, - }); - } - } catch (error) { - // Silently fail - performance tracking is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('Error tracking page load:', error); - } - } -}; - -// Track API response times -export const trackApiCall = (endpoint: string, duration: number, status: number) => { - if (typeof window === 'undefined') return; - trackEvent('api-call', { - endpoint, - duration: Math.round(duration), - status, - url: window.location.pathname, - }); -}; - -// Track user interactions -export const trackInteraction = (action: string, element?: string) => { - if (typeof window === 'undefined') return; - trackEvent('interaction', { - action, - element, - url: window.location.pathname, - }); -}; - -// Track errors -export const trackError = (error: string, context?: string) => { - if (typeof window === 'undefined') return; - trackEvent('error', { - error, - context, - url: window.location.pathname, - }); -}; diff --git a/lib/auth.ts b/lib/auth.ts index 16ee213..56b5ea4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -196,9 +196,9 @@ if (typeof window === 'undefined') { }, 60000); // Clear every minute } -export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean { +export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean { const now = Date.now(); - const key = `admin_${ip}`; + const key = `${prefix}_${ip}`; const current = rateLimitMap.get(key); @@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n return true; } -export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record { - const current = rateLimitMap.get(`admin_${ip}`); +export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record { + const current = rateLimitMap.get(`${prefix}_${ip}`); const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests; return { diff --git a/lib/useWebVitals.ts b/lib/useWebVitals.ts deleted file mode 100644 index b60bfee..0000000 --- a/lib/useWebVitals.ts +++ /dev/null @@ -1,355 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { trackWebVitals, trackPerformance } from './analytics'; - -// Web Vitals types -interface Metric { - name: string; - value: number; - delta: number; - id: string; -} - -// Simple Web Vitals implementation (since we don't want to add external dependencies) -const getCLS = (onPerfEntry: (metric: Metric) => void) => { - if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null; - - try { - let clsValue = 0; - let sessionValue = 0; - let sessionEntries: PerformanceEntry[] = []; - - const observer = new PerformanceObserver((list) => { - try { - for (const entry of list.getEntries()) { - if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) { - const firstSessionEntry = sessionEntries[0]; - const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; - - if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) { - sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0; - sessionEntries.push(entry); - } else { - sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0; - sessionEntries = [entry]; - } - - if (sessionValue > clsValue) { - clsValue = sessionValue; - onPerfEntry({ - name: 'CLS', - value: clsValue, - delta: clsValue, - id: `cls-${Date.now()}`, - }); - } - } - } - } catch (error) { - // Silently fail - CLS tracking is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('CLS tracking error:', error); - } - } - }); - - observer.observe({ type: 'layout-shift', buffered: true }); - return observer; - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('CLS observer initialization failed:', error); - } - return null; - } -}; - -const getFID = (onPerfEntry: (metric: Metric) => void) => { - if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null; - - try { - const observer = new PerformanceObserver((list) => { - try { - for (const entry of list.getEntries()) { - const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart; - if (processingStart !== undefined) { - onPerfEntry({ - name: 'FID', - value: processingStart - entry.startTime, - delta: processingStart - entry.startTime, - id: `fid-${Date.now()}`, - }); - } - } - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('FID tracking error:', error); - } - } - }); - - observer.observe({ type: 'first-input', buffered: true }); - return observer; - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('FID observer initialization failed:', error); - } - return null; - } -}; - -const getFCP = (onPerfEntry: (metric: Metric) => void) => { - if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null; - - try { - const observer = new PerformanceObserver((list) => { - try { - for (const entry of list.getEntries()) { - if (entry.name === 'first-contentful-paint') { - onPerfEntry({ - name: 'FCP', - value: entry.startTime, - delta: entry.startTime, - id: `fcp-${Date.now()}`, - }); - } - } - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('FCP tracking error:', error); - } - } - }); - - observer.observe({ type: 'paint', buffered: true }); - return observer; - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('FCP observer initialization failed:', error); - } - return null; - } -}; - -const getLCP = (onPerfEntry: (metric: Metric) => void) => { - if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null; - - try { - const observer = new PerformanceObserver((list) => { - try { - const entries = list.getEntries(); - const lastEntry = entries[entries.length - 1]; - - if (lastEntry) { - onPerfEntry({ - name: 'LCP', - value: lastEntry.startTime, - delta: lastEntry.startTime, - id: `lcp-${Date.now()}`, - }); - } - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('LCP tracking error:', error); - } - } - }); - - observer.observe({ type: 'largest-contentful-paint', buffered: true }); - return observer; - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('LCP observer initialization failed:', error); - } - return null; - } -}; - -const getTTFB = (onPerfEntry: (metric: Metric) => void) => { - if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null; - - try { - const observer = new PerformanceObserver((list) => { - try { - for (const entry of list.getEntries()) { - if (entry.entryType === 'navigation') { - const navEntry = entry as PerformanceNavigationTiming; - if (navEntry.responseStart && navEntry.fetchStart) { - onPerfEntry({ - name: 'TTFB', - value: navEntry.responseStart - navEntry.fetchStart, - delta: navEntry.responseStart - navEntry.fetchStart, - id: `ttfb-${Date.now()}`, - }); - } - } - } - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('TTFB tracking error:', error); - } - } - }); - - observer.observe({ type: 'navigation', buffered: true }); - return observer; - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('TTFB observer initialization failed:', error); - } - return null; - } -}; - -// Custom hook for Web Vitals tracking -export const useWebVitals = () => { - useEffect(() => { - if (typeof window === 'undefined') return; - - // Wrap everything in try-catch to prevent errors from breaking the app - try { - const safeNow = () => { - if (typeof performance !== "undefined" && typeof performance.now === "function") { - return performance.now(); - } - return Date.now(); - }; - - // Store web vitals for batch sending - const webVitals: Record = {}; - const path = window.location.pathname; - const projectMatch = path.match(/\/projects\/([^\/]+)/); - const projectId = projectMatch ? projectMatch[1] : null; - const observers: PerformanceObserver[] = []; - - const sendWebVitals = async () => { - if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS - try { - await fetch('/api/analytics/track', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: 'performance', - projectId: projectId, - page: path, - performance: { - fcp: webVitals.FCP || 0, - lcp: webVitals.LCP || 0, - cls: webVitals.CLS || 0, - fid: webVitals.FID || 0, - ttfb: webVitals.TTFB || 0, - loadTime: safeNow() - } - }) - }); - } catch (error) { - // Silently fail - if (process.env.NODE_ENV === 'development') { - console.error('Error sending web vitals:', error); - } - } - } - }; - - // Track Core Web Vitals - const clsObserver = getCLS((metric) => { - webVitals.CLS = metric.value; - trackWebVitals({ - ...metric, - name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', - url: window.location.pathname, - }); - sendWebVitals(); - }); - if (clsObserver) observers.push(clsObserver); - - const fidObserver = getFID((metric) => { - webVitals.FID = metric.value; - trackWebVitals({ - ...metric, - name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', - url: window.location.pathname, - }); - sendWebVitals(); - }); - if (fidObserver) observers.push(fidObserver); - - const fcpObserver = getFCP((metric) => { - webVitals.FCP = metric.value; - trackWebVitals({ - ...metric, - name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', - url: window.location.pathname, - }); - sendWebVitals(); - }); - if (fcpObserver) observers.push(fcpObserver); - - const lcpObserver = getLCP((metric) => { - webVitals.LCP = metric.value; - trackWebVitals({ - ...metric, - name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', - url: window.location.pathname, - }); - sendWebVitals(); - }); - if (lcpObserver) observers.push(lcpObserver); - - const ttfbObserver = getTTFB((metric) => { - webVitals.TTFB = metric.value; - trackWebVitals({ - ...metric, - name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', - url: window.location.pathname, - }); - sendWebVitals(); - }); - if (ttfbObserver) observers.push(ttfbObserver); - - // Track page load performance - const handleLoad = () => { - setTimeout(() => { - trackPerformance({ - name: 'page-load-complete', - value: safeNow(), - url: window.location.pathname, - timestamp: Date.now(), - userAgent: navigator.userAgent, - }); - }, 0); - }; - - if (document.readyState === 'complete') { - handleLoad(); - } else { - window.addEventListener('load', handleLoad); - } - - return () => { - // Cleanup all observers - observers.forEach(observer => { - try { - observer.disconnect(); - } catch { - // Silently fail - } - }); - try { - window.removeEventListener('load', handleLoad); - } catch { - // Silently fail - } - }; - } catch (error) { - // If Web Vitals initialization fails, don't break the app - if (process.env.NODE_ENV === 'development') { - console.warn('Web Vitals initialization failed:', error); - } - // Return empty cleanup function - return () => {}; - } - }, []); -};