From 40d94893955bd009af5b49f595890a3f65e18901 Mon Sep 17 00:00:00 2001 From: denshooter Date: Sat, 10 Jan 2026 03:08:25 +0100 Subject: [PATCH] feat: enhance analytics and performance tracking with real data metrics - Integrate real page view data from the database for accurate analytics. - Implement cache-busting for fresh data retrieval in analytics dashboard. - Calculate and display bounce rate, average session duration, and unique users. - Refactor performance metrics to ensure only real data is considered. - Improve user experience with toast notifications for success and error messages. - Update project editor with undo/redo functionality and enhanced content management. --- app/api/analytics/dashboard/route.ts | 114 ++++- app/api/analytics/performance/route.ts | 64 +++ app/api/analytics/reset/route.ts | 177 ++++--- app/api/analytics/track/route.ts | 173 +++++++ app/editor/page.tsx | 662 ++++++++++++++++--------- app/manage/page.tsx | 4 +- app/projects/[slug]/page.tsx | 23 +- components/AnalyticsDashboard.tsx | 152 +++--- components/AnalyticsProvider.tsx | 85 +++- components/ModernAdminDashboard.tsx | 58 ++- components/Toast.tsx | 42 +- lib/useWebVitals.ts | 47 ++ 12 files changed, 1156 insertions(+), 445 deletions(-) create mode 100644 app/api/analytics/track/route.ts diff --git a/app/api/analytics/dashboard/route.ts b/app/api/analytics/dashboard/route.ts index 22b8ec9..ce5a229 100644 --- a/app/api/analytics/dashboard/route.ts +++ b/app/api/analytics/dashboard/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { projectService } from '@/lib/prisma'; +import { prisma, projectService } from '@/lib/prisma'; import { analyticsCache } from '@/lib/redis'; import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; @@ -30,10 +30,15 @@ export async function GET(request: NextRequest) { } } - // Check cache first - const cachedStats = await analyticsCache.getOverallStats(); - if (cachedStats) { - return NextResponse.json(cachedStats); + // 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 @@ -41,28 +46,84 @@ export async function GET(request: NextRequest) { const projects = projectsResult.projects || projectsResult; const performanceStats = await projectService.getPerformanceStats(); + // Get real page view data from database + const allPageViews = await prisma.pageView.findMany({ + where: { + timestamp: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // Last 30 days + } + } + }); + + // Calculate bounce rate (sessions with only 1 pageview) + const pageViewsByIP = allPageViews.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 (simplified - time between first and last pageview per IP) + 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; + + // Get total unique users (unique IPs) + const totalUsers = new Set(allPageViews.map(pv => pv.ip).filter(Boolean)).size; + + // Calculate real views from PageView table + const viewsByProject = allPageViews.reduce((acc, pv) => { + if (pv.projectId) { + acc[pv.projectId] = (acc[pv.projectId] || 0) + 1; + } + 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: projects.reduce((sum, p) => sum + ((p.analytics as Record)?.views as number || 0), 0), - totalLikes: projects.reduce((sum, p) => sum + ((p.analytics as Record)?.likes as number || 0), 0), - totalShares: projects.reduce((sum, p) => sum + ((p.analytics as Record)?.shares as number || 0), 0), - avgLighthouse: projects.length > 0 - ? Math.round(projects.reduce((sum, p) => sum + ((p.performance as Record)?.lighthouse as number || 0), 0) / projects.length) - : 0 + totalViews: allPageViews.length, // 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: (project.analytics as Record)?.views as number || 0, - likes: (project.analytics as Record)?.likes as number || 0, - shares: (project.analytics as Record)?.shares as number || 0, - lighthouse: (project.performance as Record)?.lighthouse as number || 0, + 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, @@ -71,10 +132,25 @@ export async function GET(request: NextRequest) { categories: performanceStats.byCategory, difficulties: performanceStats.byDifficulty, performance: { - avgLighthouse: performanceStats.avgLighthouse, - totalViews: performanceStats.totalViews, - totalLikes: performanceStats.totalLikes, - totalShares: performanceStats.totalShares + 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: allPageViews.length, // Real total views + totalLikes: 0, + totalShares: 0 + }, + metrics: { + bounceRate, + avgSessionDuration, + pagesPerSession: totalSessions > 0 ? (allPageViews.length / totalSessions).toFixed(1) : '0', + newUsers: totalUsers, + totalUsers } }; diff --git a/app/api/analytics/performance/route.ts b/app/api/analytics/performance/route.ts index c0e19a9..b85c76a 100644 --- a/app/api/analytics/performance/route.ts +++ b/app/api/analytics/performance/route.ts @@ -24,8 +24,72 @@ export async function GET(request: NextRequest) { 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 + })); + + 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 => { diff --git a/app/api/analytics/reset/route.ts b/app/api/analytics/reset/route.ts index 4cbbcf6..cef0172 100644 --- a/app/api/analytics/reset/route.ts +++ b/app/api/analytics/reset/route.ts @@ -33,86 +33,15 @@ export async function POST(request: NextRequest) { switch (type) { case 'analytics': - // Reset all project analytics - await prisma.project.updateMany({ - data: { - 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 - await prisma.project.updateMany({ - data: { - performance: { - 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 - await Promise.all([ - // Reset analytics - prisma.project.updateMany({ + // 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, @@ -140,11 +69,30 @@ export async function POST(request: NextRequest) { lastUpdated: new Date().toISOString() } } - }), - // Reset performance - prisma.project.updateMany({ + }); + } + 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, @@ -166,6 +114,73 @@ export async function POST(request: NextRequest) { 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({}), diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts new file mode 100644 index 0000000..abb84e8 --- /dev/null +++ b/app/api/analytics/track/route.ts @@ -0,0 +1,173 @@ +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) { + const projectIdNum = projectId ? parseInt(projectId.toString()) : 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 }, + { title: { contains: slug, mode: 'insensitive' } } + ] + } + }); + 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/editor/page.tsx b/app/editor/page.tsx index 4f1ed8b..507f7d8 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -29,6 +29,7 @@ import { Github, Tag, } from "lucide-react"; +import { useToast } from "@/components/Toast"; interface Project { id: string; @@ -50,6 +51,7 @@ function EditorPageContent() { const searchParams = useSearchParams(); const projectId = searchParams.get("id"); const contentRef = useRef(null); + const { showSuccess, showError } = useToast(); const [, setProject] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -58,6 +60,10 @@ function EditorPageContent() { const [isCreating, setIsCreating] = useState(!projectId); const [showPreview, setShowPreview] = useState(false); const [isTyping, setIsTyping] = useState(false); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [originalFormData, setOriginalFormData] = useState(null); + const shouldUpdateContentRef = useRef(true); // Form state const [formData, setFormData] = useState({ @@ -84,8 +90,7 @@ function EditorPageContent() { ); if (foundProject) { - setProject(foundProject); - setFormData({ + const initialData = { title: foundProject.title || "", description: foundProject.description || "", content: foundProject.content || "", @@ -96,7 +101,19 @@ function EditorPageContent() { github: foundProject.github || "", live: foundProject.live || "", image: foundProject.image || "", - }); + }; + setProject(foundProject); + setFormData(initialData); + setOriginalFormData(initialData); + setHistory([initialData]); + setHistoryIndex(0); + shouldUpdateContentRef.current = true; + // Initialize contentEditable after state update + setTimeout(() => { + if (contentRef.current && initialData.content) { + contentRef.current.textContent = initialData.content; + } + }, 0); } } else { if (process.env.NODE_ENV === "development") { @@ -126,6 +143,30 @@ function EditorPageContent() { await loadProject(projectId); } else { setIsCreating(true); + // Initialize history for new project + const initialData = { + title: "", + description: "", + content: "", + category: "web", + tags: [], + featured: false, + published: false, + github: "", + live: "", + image: "", + }; + setFormData(initialData); + setOriginalFormData(initialData); + setHistory([initialData]); + setHistoryIndex(0); + shouldUpdateContentRef.current = true; + // Initialize contentEditable after state update + setTimeout(() => { + if (contentRef.current) { + contentRef.current.textContent = ""; + } + }, 0); } } else { setIsAuthenticated(false); @@ -143,18 +184,20 @@ function EditorPageContent() { init(); }, [projectId, loadProject]); - const handleSave = async () => { + const handleSave = useCallback(async () => { try { setIsSaving(true); // Validate required fields if (!formData.title.trim()) { - alert("Please enter a project title"); + showError("Validation Error", "Please enter a project title"); + setIsSaving(false); return; } if (!formData.description.trim()) { - alert("Please enter a project description"); + showError("Validation Error", "Please enter a project description"); + setIsSaving(false); return; } @@ -205,40 +248,156 @@ function EditorPageContent() { image: savedProject.imageUrl || "", })); - // Show success and redirect - alert("Project saved successfully!"); - setTimeout(() => { - window.location.href = "/manage"; - }, 1000); + // Show success toast (smaller, smoother) + showSuccess("Saved", `"${savedProject.title}" saved`); + + // Update project ID if it was a new project + if (!projectId && savedProject.id) { + const newUrl = `/editor?id=${savedProject.id}`; + window.history.replaceState({}, '', newUrl); + } } else { const errorData = await response.json(); if (process.env.NODE_ENV === "development") { console.error("Error saving project:", response.status, errorData); } - alert(`Error saving project: ${errorData.error || "Unknown error"}`); + showError("Save Failed", errorData.error || "Failed to save"); } } catch (error) { if (process.env.NODE_ENV === "development") { console.error("Error saving project:", error); } - alert( - `Error saving project: ${error instanceof Error ? error.message : "Unknown error"}`, + showError( + "Save Failed", + error instanceof Error ? error.message : "Failed to save" ); } finally { setIsSaving(false); } - }; + }, [projectId, formData, showSuccess, showError]); const handleInputChange = ( field: string, value: string | boolean | string[], ) => { - setFormData((prev) => ({ - ...prev, - [field]: value, - })); + setFormData((prev) => { + const newData = { + ...prev, + [field]: value, + }; + // Add to history for undo/redo + setHistory((hist) => { + const newHistory = hist.slice(0, historyIndex + 1); + newHistory.push(newData); + // Keep only last 50 history entries + const trimmedHistory = newHistory.slice(-50); + setHistoryIndex(trimmedHistory.length - 1); + return trimmedHistory; + }); + return newData; + }); }; + const handleUndo = useCallback(() => { + setHistoryIndex((currentIndex) => { + if (currentIndex > 0) { + const newIndex = currentIndex - 1; + shouldUpdateContentRef.current = true; + setFormData(history[newIndex]); + return newIndex; + } + return currentIndex; + }); + }, [history]); + + const handleRedo = useCallback(() => { + setHistoryIndex((currentIndex) => { + if (currentIndex < history.length - 1) { + const newIndex = currentIndex + 1; + shouldUpdateContentRef.current = true; + setFormData(history[newIndex]); + return newIndex; + } + return currentIndex; + }); + }, [history]); + + const handleRevert = useCallback(() => { + if (originalFormData) { + if (confirm("Are you sure you want to revert all changes? This cannot be undone.")) { + shouldUpdateContentRef.current = true; + setFormData(originalFormData); + setHistory([originalFormData]); + setHistoryIndex(0); + } + } else if (projectId) { + // Reload from server + if (confirm("Are you sure you want to revert all changes? This will reload the project from the server.")) { + shouldUpdateContentRef.current = true; + loadProject(projectId); + } + } + }, [originalFormData, projectId, loadProject]); + + // Sync contentEditable when formData.content changes externally (undo/redo/revert) + useEffect(() => { + if (contentRef.current && shouldUpdateContentRef.current) { + const currentContent = contentRef.current.textContent || ""; + if (currentContent !== formData.content) { + contentRef.current.textContent = formData.content; + } + shouldUpdateContentRef.current = false; + } + }, [formData.content]); + + // Initialize contentEditable when formData.content is set and editor is empty + useEffect(() => { + if (contentRef.current) { + const currentText = contentRef.current.textContent || ""; + // Initialize if editor is empty and we have content, or if content changed externally + if ((!currentText && formData.content) || (shouldUpdateContentRef.current && currentText !== formData.content)) { + contentRef.current.textContent = formData.content; + shouldUpdateContentRef.current = false; + } + } + }, [formData.content]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+S or Cmd+S - Save + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + if (!isSaving) { + handleSave(); + } + } + // Ctrl+Z or Cmd+Z - Undo + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } + // Ctrl+Shift+Z or Cmd+Shift+Z - Redo + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) { + e.preventDefault(); + handleRedo(); + } + // Ctrl+R or Cmd+R - Revert (but allow browser refresh if not in editor) + if ((e.ctrlKey || e.metaKey) && e.key === 'r') { + const target = e.target as HTMLElement; + if (target.isContentEditable || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + e.preventDefault(); + handleRevert(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isSaving, handleSave, handleUndo, handleRedo, handleRevert]); + const handleTagsChange = (tagsString: string) => { const tags = tagsString .split(",") @@ -358,7 +517,7 @@ function EditorPageContent() {

Loading Editor @@ -390,7 +549,7 @@ function EditorPageContent() { @@ -400,15 +559,15 @@ function EditorPageContent() { } return ( -
+
{/* Header */} -
+
- {/* Editor Content */} -
-
+ {/* Editor Content - Scrollable */} +
+
+
{/* Floating particles background */}
{[...Array(20)].map((_, i) => ( @@ -469,187 +629,12 @@ function EditorPageContent() { /> ))}
- {/* Main Editor */} -
- {/* Project Title */} - - handleInputChange("title", e.target.value)} - className="w-full text-3xl font-bold form-input-enhanced focus:outline-none p-4 rounded-lg" - placeholder="Enter project title..." - /> - - - {/* Rich Text Toolbar */} - -
-
- - - -
- -
- - - -
- -
- - - -
- -
- - -
-
-
- - {/* Content Editor */} - -

- Content -

-
{ - const target = e.target as HTMLDivElement; - setIsTyping(true); - setFormData((prev) => ({ - ...prev, - content: target.textContent || "", - })); - }} - onBlur={() => { - setIsTyping(false); - }} - suppressContentEditableWarning={true} - data-placeholder="Start writing your project content..." - > - {!isTyping ? formData.content : undefined} -
-

- Supports Markdown formatting. Use the toolbar above or type - directly. -

-
- - {/* Description */} - -

- Description -

-