From 1c41cf2292de65c44931095dabca7049a51272a8 Mon Sep 17 00:00:00 2001 From: 0de1l <0de1l@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:03:03 +0800 Subject: [PATCH 01/10] Ensure shader time reaches WebGL uniforms The homepage WebGL frame loop was drawing, but production sampling showed the fragment shader's time uniform only reached the GPU at initialization. Updating the memoized uniform object was not enough for the mounted ShaderMaterial, so the frame loop now writes to the live material uniforms and marks them for upload. Constraint: Preserve the original MiniLoad shader and visual style Rejected: Increase wave speed again | frame sampling showed the deployed issue was uniform upload, not motion magnitude Confidence: high Scope-risk: narrow Directive: Verify future animation fixes by sampling the canvas frame and uniform updates before changing shader visuals Tested: npm run build; production Chrome canvas sampling showed time uniform advancing and background frame hashes changing Not-tested: Cloudflare Pages redeploy runtime --- src/components/dither/Dither.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/dither/Dither.tsx b/src/components/dither/Dither.tsx index 03a5d76..91c7bac 100644 --- a/src/components/dither/Dither.tsx +++ b/src/components/dither/Dither.tsx @@ -197,6 +197,7 @@ function DitheredWaves({ mouseRadius }: DitheredWavesProps) { const mesh = useRef(null); + const material = useRef(null); const mouseRef = useRef(new THREE.Vector2()); const { viewport, size, gl } = useThree(); @@ -222,16 +223,20 @@ function DitheredWaves({ /* eslint-disable react-hooks/immutability */ useFrame(({ clock }) => { + const uniforms = material.current?.uniforms ?? waveUniforms; if (!disableAnimation) { - waveUniforms.time.value = clock.getElapsedTime(); + uniforms.time.value = clock.getElapsedTime(); + } + uniforms.waveSpeed.value = waveSpeed; + uniforms.waveFrequency.value = waveFrequency; + uniforms.waveAmplitude.value = waveAmplitude; + uniforms.waveColor.value.set(...waveColor); + uniforms.enableMouseInteraction.value = enableMouseInteraction ? 1 : 0; + uniforms.mouseRadius.value = mouseRadius; + uniforms.mousePos.value.copy(mouseRef.current); + if (material.current) { + material.current.uniformsNeedUpdate = true; } - waveUniforms.waveSpeed.value = waveSpeed; - waveUniforms.waveFrequency.value = waveFrequency; - waveUniforms.waveAmplitude.value = waveAmplitude; - waveUniforms.waveColor.value.set(...waveColor); - waveUniforms.enableMouseInteraction.value = enableMouseInteraction ? 1 : 0; - waveUniforms.mouseRadius.value = mouseRadius; - waveUniforms.mousePos.value.copy(mouseRef.current); }); /* eslint-enable react-hooks/immutability */ @@ -252,6 +257,7 @@ function DitheredWaves({ Date: Sun, 7 Jun 2026 22:08:14 +0800 Subject: [PATCH 02/10] Make admin traffic metrics reflect real D1 events The admin dashboard previously displayed fixed demo traffic numbers. This adds a lightweight first-party analytics event table, client-side pageview reporting, and an authenticated admin summary endpoint so deployed traffic metrics come from Cloudflare D1 instead of hardcoded arrays. Constraint: Cloudflare Pages uses D1 binding name DB and Edge API routes. Rejected: External analytics service | harder to surface inside the existing admin dashboard and adds another service dependency. Rejected: Store raw IP addresses | unnecessary for visit counting and worse for privacy. Confidence: high Scope-risk: moderate Directive: Keep /admin and /api paths excluded from tracking or admin refreshes will pollute traffic metrics. Tested: npm run build Tested: npx tsc --noEmit Tested: scoped eslint on analytics routes, tracker, layout, admin page, and auth route Not-tested: Cloudflare Pages runtime D1 write/read after deployment --- analytics-schema.sql | 16 +++ src/app/admin/page.tsx | 86 ++++++++---- src/app/api/admin/analytics/route.ts | 38 ++++++ src/app/api/admin/auth/route.ts | 6 +- src/app/api/analytics/view/route.ts | 58 ++++++++ src/app/layout.tsx | 4 + src/components/analytics-tracker.tsx | 50 +++++++ src/lib/analytics.ts | 192 +++++++++++++++++++++++++++ 8 files changed, 426 insertions(+), 24 deletions(-) create mode 100644 analytics-schema.sql create mode 100644 src/app/api/admin/analytics/route.ts create mode 100644 src/app/api/analytics/view/route.ts create mode 100644 src/components/analytics-tracker.tsx create mode 100644 src/lib/analytics.ts diff --git a/analytics-schema.sql b/analytics-schema.sql new file mode 100644 index 0000000..808717a --- /dev/null +++ b/analytics-schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS analytics_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + visitor_hash TEXT NOT NULL, + referrer TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_events_created_at +ON analytics_events(created_at); + +CREATE INDEX IF NOT EXISTS idx_analytics_events_path_created_at +ON analytics_events(path, created_at); + +CREATE INDEX IF NOT EXISTS idx_analytics_events_visitor_created_at +ON analytics_events(visitor_hash, created_at); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8796e3e..4c680c5 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -75,21 +75,46 @@ type DashboardData = { posts: AdminItem[]; daily: AdminItem[]; moments: AdminItem[]; + analytics: AnalyticsSummary; }; type ChartRange = '7d' | '30d'; -const visitorSeries: Record = { - '7d': [18, 24, 21, 32, 27, 15, 17], - '30d': [6, 9, 7, 12, 15, 10, 18, 22, 19, 16, 28, 24, 31, 35, 29, 25, 20, 26, 33, 38, 34, 41, 37, 44, 39, 31, 27, 25, 18, 17], +type AnalyticsPoint = { + date: string; + views: number; + visitors: number; }; -const viewSeries: Record = { - '7d': [72, 95, 88, 136, 121, 148, 167], - '30d': [31, 44, 38, 52, 65, 58, 73, 90, 86, 78, 96, 104, 118, 132, 126, 119, 101, 116, 135, 151, 148, 163, 156, 174, 168, 143, 132, 128, 118, 167], +type AnalyticsSummary = { + totalViews: number; + todayViews: number; + uniqueVisitors: number; + series: Record; }; -const totalVisits = 1475; +const buildEmptyAnalytics = (): AnalyticsSummary => ({ + totalViews: 0, + todayViews: 0, + uniqueVisitors: 0, + series: { + '7d': buildDateRange(7).map((date) => ({ date, views: 0, visitors: 0 })), + '30d': buildDateRange(30).map((date) => ({ date, views: 0, visitors: 0 })), + }, +}); + +const buildDateRange = (days: number) => { + const dates: string[] = []; + const now = new Date(); + + for (let offset = days - 1; offset >= 0; offset -= 1) { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + date.setUTCDate(date.getUTCDate() - offset); + dates.push(date.toISOString().slice(0, 10)); + } + + return dates; +}; const formatMetric = (value: number) => new Intl.NumberFormat('en-US').format(value); @@ -207,7 +232,7 @@ function DashboardView({ { label: '文章数', value: postCount, meta: '文章档案', icon: }, { label: '总字数', value: totalWords, meta: 'Markdown 字符', icon: }, { label: '总内容数', value: totalContent, meta: '文章 + 日常 + 瞬间', icon: }, - { label: '总访问量', value: totalVisits, meta: '今日新增 167 次', icon: }, + { label: '总访问量', value: data.analytics.totalViews, meta: `今日新增 ${formatMetric(data.analytics.todayViews)} 次`, icon: }, ]; return ( @@ -233,14 +258,14 @@ function DashboardView({ title="访客趋势图" range={visitorRange} onRangeChange={setVisitorRange} - values={visitorSeries[visitorRange]} + values={data.analytics.series[visitorRange].map((point) => point.visitors)} unit="人" /> point.views)} unit="次" /> @@ -321,7 +346,7 @@ export default function AdminPage() { const [dailyData, setDailyData] = useState({ date: today, imageUrl: '', content: '' }); const [momentData, setMomentData] = useState({ title: '', date: today, imageUrl: '', content: '' }); const [existingPosts, setExistingPosts] = useState([]); - const [dashboardData, setDashboardData] = useState({ posts: [], daily: [], moments: [] }); + const [dashboardData, setDashboardData] = useState({ posts: [], daily: [], moments: [], analytics: buildEmptyAnalytics() }); const [dashboardLoading, setDashboardLoading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [currentFilename, setCurrentFilename] = useState(null); @@ -375,27 +400,32 @@ export default function AdminPage() { try { const adminKey = token || localStorage.getItem('admin_key') || ''; setDashboardLoading(true); - const [postsRes, dailyRes, momentsRes] = await Promise.all( - ['post', 'daily', 'moment'].map((targetType) => fetch(`/api/admin/list?type=${targetType}`, { + const [postsRes, dailyRes, momentsRes, analyticsRes] = await Promise.all([ + ...(['post', 'daily', 'moment'] as const).map((targetType) => fetch(`/api/admin/list?type=${targetType}`, { headers: { 'Authorization': adminKey } - })) - ); + })), + fetch('/api/admin/analytics', { + headers: { 'Authorization': adminKey } + }), + ]); - if ([postsRes, dailyRes, momentsRes].some((res) => res.status === 401)) { + if ([postsRes, dailyRes, momentsRes, analyticsRes].some((res) => res.status === 401)) { setIsAuthorized(false); return; } - const [postsData, dailyDataRes, momentsData] = await Promise.all([ + const [postsData, dailyDataRes, momentsData, analyticsData] = await Promise.all([ postsRes.json() as Promise<{ items: AdminItem[] }>, dailyRes.json() as Promise<{ items: AdminItem[] }>, momentsRes.json() as Promise<{ items: AdminItem[] }>, + analyticsRes.ok ? analyticsRes.json() as Promise : Promise.resolve(buildEmptyAnalytics()), ]); setDashboardData({ posts: postsData.items || [], daily: dailyDataRes.items || [], moments: momentsData.items || [], + analytics: analyticsData || buildEmptyAnalytics(), }); } catch (error) { console.error('Failed to fetch dashboard data', error); @@ -431,12 +461,22 @@ export default function AdminPage() { }, [fetchDashboardData, type]); useEffect(() => { - const key = localStorage.getItem('admin_key'); - if (key) { - setIsAuthorized(true); - fetchPosts(key); - } - setCheckingAuth(false); + let cancelled = false; + + queueMicrotask(() => { + if (cancelled) return; + + const key = localStorage.getItem('admin_key'); + if (key) { + setIsAuthorized(true); + fetchPosts(key); + } + setCheckingAuth(false); + }); + + return () => { + cancelled = true; + }; }, [fetchPosts]); useEffect(() => { diff --git a/src/app/api/admin/analytics/route.ts b/src/app/api/admin/analytics/route.ts new file mode 100644 index 0000000..08a074c --- /dev/null +++ b/src/app/api/admin/analytics/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + emptyAnalyticsSummary, + getAdminPassword, + getAnalyticsDb, + getAnalyticsSummary, + getRuntimeEnv, +} from '@/lib/analytics'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'edge'; + +export async function GET(request: NextRequest) { + const env = getRuntimeEnv(); + const authHeader = request.headers.get('Authorization'); + + if (authHeader !== getAdminPassword(env)) { + const response = NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + response.headers.set('Cache-Control', 'no-store'); + return response; + } + + const db = getAnalyticsDb(env); + if (!db) { + const response = NextResponse.json(emptyAnalyticsSummary()); + response.headers.set('Cache-Control', 'no-store'); + return response; + } + + try { + const response = NextResponse.json(await getAnalyticsSummary(db)); + response.headers.set('Cache-Control', 'no-store'); + return response; + } catch (error) { + console.error('Failed to load analytics summary:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/auth/route.ts b/src/app/api/admin/auth/route.ts index 21293fa..03b5fc3 100644 --- a/src/app/api/admin/auth/route.ts +++ b/src/app/api/admin/auth/route.ts @@ -3,9 +3,13 @@ import { NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; export const runtime = 'edge'; +type AuthPayload = { + password?: string; +}; + export async function POST(request: Request) { try { - const { password } = await request.json(); + const { password } = (await request.json()) as AuthPayload; const adminPassword = process.env.ADMIN_PASSWORD || ''; if (password === adminPassword) { diff --git a/src/app/api/analytics/view/route.ts b/src/app/api/analytics/view/route.ts new file mode 100644 index 0000000..3d9cbab --- /dev/null +++ b/src/app/api/analytics/view/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + ensureAnalyticsSchema, + getAnalyticsDb, + getClientIp, + getRuntimeEnv, + hashVisitor, + normalizePath, + shouldTrackPath, +} from '@/lib/analytics'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'edge'; + +type ViewPayload = { + path?: unknown; + referrer?: unknown; +}; + +export async function POST(request: NextRequest) { + const env = getRuntimeEnv(); + const db = getAnalyticsDb(env); + + if (!db) { + return NextResponse.json({ tracked: false, reason: 'D1 database not available' }, { status: 202 }); + } + + let payload: ViewPayload = {}; + try { + payload = (await request.json()) as ViewPayload; + } catch { + payload = {}; + } + + const path = normalizePath(payload.path); + if (!shouldTrackPath(path)) { + return NextResponse.json({ tracked: false, reason: 'path excluded' }); + } + + const userAgent = request.headers.get('user-agent') || 'unknown'; + const acceptLanguage = request.headers.get('accept-language') || ''; + const ip = getClientIp(request); + const visitorHash = await hashVisitor(`${ip}:${userAgent}:${acceptLanguage}`, env); + const referrer = typeof payload.referrer === 'string' ? payload.referrer.slice(0, 500) : ''; + + try { + await ensureAnalyticsSchema(db); + await db.prepare(` + INSERT INTO analytics_events (path, visitor_hash, referrer) + VALUES (?, ?, ?) + `).bind(path, visitorHash, referrer).run(); + + return NextResponse.json({ tracked: true }); + } catch (error) { + console.error('Analytics view tracking failed:', error); + return NextResponse.json({ tracked: false, error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2821851..c2574eb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Press_Start_2P } from "next/font/google"; import "./globals.css"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; +import { AnalyticsTracker } from "@/components/analytics-tracker"; const pressStart2P = Press_Start_2P({ weight: "400", @@ -85,6 +86,9 @@ export default function RootLayout({
+ + +
{children}