Compare commits

...

19 Commits

Author SHA1 Message Date
denshooter
9cc03bc475 Prevent white screen: wrap ActivityFeed in error boundary and improve ClientProviders error handling
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m10s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 11m4s
2026-01-10 17:08:16 +01:00
denshooter
832b468ea7 Fix white screen: add error boundaries and improve error handling in AnalyticsProvider and useWebVitals 2026-01-10 17:07:00 +01:00
denshooter
2a260abe0a Fix ActivityFeed fetch TypeError: add proper error handling and type safety 2026-01-10 17:03:07 +01:00
denshooter
80f2ac61ac Fix type error in KernelPanic404: update currentMusic type to match return type 2026-01-10 16:55:01 +01:00
denshooter
a980ee8fcd Fix runtime errors: PerformanceObserver, localStorage, crypto.randomUUID, hydration issues, and linting errors 2026-01-10 16:54:28 +01:00
denshooter
ca2ed13446 refactor: enhance error handling and performance tracking across components
- Improve localStorage access in ActivityFeed, ChatWidget, and AdminPage with try-catch blocks to handle potential errors gracefully.
- Update performance tracking in AnalyticsProvider and analytics.ts to ensure robust error handling and prevent failures from affecting user experience.
- Refactor Web Vitals tracking to include error handling for observer initialization and data collection.
- Ensure consistent handling of hydration mismatches in components like BackgroundBlobs and ChatWidget to improve rendering reliability.
2026-01-10 16:53:06 +01:00
denshooter
20f0ccb85b refactor: improve 404 page loading experience and styling
- Replace Suspense with useEffect for better control over component mounting.
- Update loading indicators with fixed positioning and enhanced styling for a terminal-like appearance.
- Modify KernelPanic404 component to improve text color handling and ensure proper visibility.
- Introduce checks for 404 page detection based on pathname and data attributes for more accurate rendering.
2026-01-10 03:41:22 +01:00
denshooter
59cc8ee154 refactor: consolidate contact API logic and enhance error handling
- Migrate contact API from route.tsx to route.ts for improved organization.
- Implement filtering, pagination, and rate limiting for GET and POST requests.
- Enhance error handling for database operations, including graceful handling of missing tables.
- Validate input fields and email format in POST requests to ensure data integrity.
2026-01-10 03:13:03 +01:00
denshooter
40d9489395 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.
2026-01-10 03:08:25 +01:00
denshooter
b051d9d2ef style: refine admin dashboard and project management UI with cohesive color palette and improved readability
- Update background colors and text styles for better contrast and legibility.
- Enhance button styles and hover effects for a more modern look.
- Remove unnecessary scaling effects and adjust border styles for consistency.
- Introduce a cohesive design language across components to improve user experience.
2026-01-10 02:40:50 +01:00
denshooter
7d84d35f09 fix: resolve styling issues in admin dashboard and login
Fix login page background color to cream/stone (hide blobs). Remove hover scaling from dashboard stats cards. darkening Admin Panel and Portfolio text.
2026-01-10 02:30:15 +01:00
denshooter
59eb32b45a fix: update admin dashboard styles
Fix white text color on cream background in Project Management section. Remove hover scaling effect from login button.
2026-01-10 02:23:14 +01:00
denshooter
632302fb54 style: enhance project covers with mesh gradients, shine effects, and texture 2026-01-10 01:15:03 +01:00
denshooter
2844b981bb style: modernize project pages with warm organic design and improved readability 2026-01-10 01:13:07 +01:00
denshooter
82b5ca4514 style: modernize logo with sans-serif font and stronger red accent 2026-01-10 01:09:39 +01:00
denshooter
98f1a07b08 style: enhance glassmorphism for projects and chat widget with improved transparency and readability 2026-01-10 01:07:49 +01:00
denshooter
792f0c8aae style: modernize chat widget with glassmorphism and improve mobile layout 2026-01-10 01:05:08 +01:00
denshooter
eaaee17bca style: update chat widget to use warm organic modern color palette 2026-01-10 01:02:58 +01:00
denshooter
ae37294b06 full upgrade 2026-01-10 00:52:08 +01:00
41 changed files with 7142 additions and 2643 deletions

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma'; import { prisma, projectService } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis'; import { analyticsCache } from '@/lib/redis';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
@@ -30,10 +30,15 @@ export async function GET(request: NextRequest) {
} }
} }
// Check cache first // Check cache first (but allow bypass with cache-bust parameter)
const cachedStats = await analyticsCache.getOverallStats(); const url = new URL(request.url);
if (cachedStats) { const bypassCache = url.searchParams.get('nocache') === 'true';
return NextResponse.json(cachedStats);
if (!bypassCache) {
const cachedStats = await analyticsCache.getOverallStats();
if (cachedStats) {
return NextResponse.json(cachedStats);
}
} }
// Get analytics data // Get analytics data
@@ -41,28 +46,84 @@ export async function GET(request: NextRequest) {
const projects = projectsResult.projects || projectsResult; const projects = projectsResult.projects || projectsResult;
const performanceStats = await projectService.getPerformanceStats(); 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<string, typeof allPageViews>);
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<number, number>);
// Calculate analytics metrics // Calculate analytics metrics
const analytics = { const analytics = {
overview: { overview: {
totalProjects: projects.length, totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length, publishedProjects: projects.filter(p => p.published).length,
featuredProjects: projects.filter(p => p.featured).length, featuredProjects: projects.filter(p => p.featured).length,
totalViews: projects.reduce((sum, p) => sum + ((p.analytics as Record<string, unknown>)?.views as number || 0), 0), totalViews: allPageViews.length, // Real views from PageView table
totalLikes: projects.reduce((sum, p) => sum + ((p.analytics as Record<string, unknown>)?.likes as number || 0), 0), totalLikes: 0, // Not implemented - no like buttons
totalShares: projects.reduce((sum, p) => sum + ((p.analytics as Record<string, unknown>)?.shares as number || 0), 0), totalShares: 0, // Not implemented - no share buttons
avgLighthouse: projects.length > 0 avgLighthouse: (() => {
? Math.round(projects.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projects.length) // Only calculate if we have real performance data (not defaults)
: 0 const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
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<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
: 0;
})()
}, },
projects: projects.map(project => ({ projects: projects.map(project => ({
id: project.id, id: project.id,
title: project.title, title: project.title,
category: project.category, category: project.category,
difficulty: project.difficulty, difficulty: project.difficulty,
views: (project.analytics as Record<string, unknown>)?.views as number || 0, views: viewsByProject[project.id] || 0, // Only real views from PageView table
likes: (project.analytics as Record<string, unknown>)?.likes as number || 0, likes: 0, // Not implemented
shares: (project.analytics as Record<string, unknown>)?.shares as number || 0, shares: 0, // Not implemented
lighthouse: (project.performance as Record<string, unknown>)?.lighthouse as number || 0, lighthouse: (() => {
const perf = (project.performance as Record<string, unknown>) || {};
const score = perf.lighthouse as number || 0;
return score > 0 ? score : 0; // Only return if we have real data
})(),
published: project.published, published: project.published,
featured: project.featured, featured: project.featured,
createdAt: project.createdAt, createdAt: project.createdAt,
@@ -71,10 +132,25 @@ export async function GET(request: NextRequest) {
categories: performanceStats.byCategory, categories: performanceStats.byCategory,
difficulties: performanceStats.byDifficulty, difficulties: performanceStats.byDifficulty,
performance: { performance: {
avgLighthouse: performanceStats.avgLighthouse, avgLighthouse: (() => {
totalViews: performanceStats.totalViews, const projectsWithPerf = projects.filter(p => {
totalLikes: performanceStats.totalLikes, const perf = (p.performance as Record<string, unknown>) || {};
totalShares: performanceStats.totalShares return (perf.lighthouse as number || 0) > 0;
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.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
} }
}; };

View File

@@ -24,8 +24,73 @@ export async function GET(request: NextRequest) {
take: 1000 // Last 1000 interactions 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<string, unknown>)?.lighthouse as number) || 0,
loadTime: ((p.performance as Record<string, unknown>)?.loadTime as number) || 0,
fcp: ((p.performance as Record<string, unknown>)?.firstContentfulPaint as number) || 0,
lcp: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.lcp as number || 0,
cls: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.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<string, typeof pageViews>);
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 // Calculate performance metrics
const performance = { const performance = {
avgLighthouse: (() => {
const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
return (perf.lighthouse as number || 0) > 0;
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => {
const perf = (p.performance as Record<string, unknown>) || {};
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: { pageViews: {
total: pageViews.length, total: pageViews.length,
last24h: pageViews.filter(pv => { last24h: pageViews.filter(pv => {

View File

@@ -33,86 +33,15 @@ export async function POST(request: NextRequest) {
switch (type) { switch (type) {
case 'analytics': case 'analytics':
// Reset all project analytics // Reset all project analytics (view counts in project.analytics JSON)
await prisma.project.updateMany({ const projects = await prisma.project.findMany();
data: { for (const project of projects) {
analytics: { const analytics = (project.analytics as Record<string, unknown>) || {};
views: 0, await prisma.project.update({
likes: 0, where: { id: project.id },
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({
data: { data: {
analytics: { analytics: {
...analytics,
views: 0, views: 0,
likes: 0, likes: 0,
shares: 0, shares: 0,
@@ -140,11 +69,30 @@ export async function POST(request: NextRequest) {
lastUpdated: new Date().toISOString() 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<string, unknown>) || {};
await prisma.project.update({
where: { id: project.id },
data: { data: {
performance: { performance: {
...perf,
lighthouse: 0, lighthouse: 0,
loadTime: 0, loadTime: 0,
firstContentfulPaint: 0, firstContentfulPaint: 0,
@@ -166,6 +114,73 @@ export async function POST(request: NextRequest) {
lastUpdated: new Date().toISOString() 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<string, unknown>) || {};
const perf = (project.performance as Record<string, unknown>) || {};
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 // Clear tracking tables
prisma.pageView.deleteMany({}), prisma.pageView.deleteMany({}),

View File

@@ -0,0 +1,174 @@
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<string, unknown>) || {};
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<string, unknown>) || {};
const analytics = (project.analytics as Record<string, unknown>) || {};
// 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<string, unknown>)?.lcp || 0,
fid: fid || (perf.coreWebVitals as Record<string, unknown>)?.fid || 0,
cls: cls || (perf.coreWebVitals as Record<string, unknown>)?.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 }
);
}
}

View File

@@ -1,9 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { PrismaClient } from '@prisma/client';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {

View File

@@ -3,412 +3,199 @@ import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport"; import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer"; import Mail from "nodemailer/lib/mailer";
// Email templates with beautiful designs const BRAND = {
siteUrl: "https://dk0.dev",
email: "contact@dk0.dev",
bg: "#FDFCF8",
sand: "#F3F1E7",
border: "#E7E5E4",
text: "#292524",
muted: "#78716C",
mint: "#A7F3D0",
red: "#EF4444",
};
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function nl2br(input: string): string {
return input.replace(/\r\n|\r|\n/g, "<br>");
}
function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) {
const sentAt = new Date().toLocaleString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(opts.title)}</title>
</head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:${BRAND.text};">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid ${BRAND.border};border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<div style="background:${BRAND.text};padding:22px 26px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:800;font-size:16px;color:${BRAND.bg};">Dennis Konkol</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:800;font-size:14px;color:${BRAND.bg};">
dk<span style="color:${BRAND.red};">0</span>.dev
</div>
</div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:900;letter-spacing:-0.02em;color:${BRAND.bg};">${escapeHtml(opts.title)}</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">${escapeHtml(opts.subtitle)}${sentAt}</div>
</div>
<div style="height:3px;background:${BRAND.mint};margin-top:18px;border-radius:999px;"></div>
</div>
<div style="padding:26px;">
${opts.bodyHtml}
</div>
<div style="padding:18px 26px;background:${BRAND.bg};border-top:1px solid ${BRAND.border};">
<div style="font-size:12px;color:${BRAND.muted};line-height:1.5;">
Automatisch generiert von <a href="${BRAND.siteUrl}" style="color:${BRAND.text};text-decoration:underline;">dk0.dev</a> •
<a href="mailto:${BRAND.email}" style="color:${BRAND.text};text-decoration:underline;">${BRAND.email}</a>
</div>
</div>
</div>
</div>
</body>
</html>
`.trim();
}
const emailTemplates = { const emailTemplates = {
welcome: { welcome: {
subject: "Vielen Dank für deine Nachricht! 👋", subject: "Vielen Dank für deine Nachricht! 👋",
template: (name: string, originalMessage: string) => ` template: (name: string, originalMessage: string) => {
<!DOCTYPE html> const safeName = escapeHtml(name);
<html lang="de"> const safeMsg = nl2br(escapeHtml(originalMessage));
<head> return baseEmail({
<meta charset="UTF-8"> title: `Danke, ${safeName}!`,
<meta name="viewport" content="width=device-width, initial-scale=1.0"> subtitle: "Nachricht erhalten",
<title>Willkommen - Dennis Konkol</title> bodyHtml: `
</head> <div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;"> Hey ${safeName},<br><br>
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);"> danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
</div>
<!-- Header -->
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 40px 30px; text-align: center;"> <div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;"> <div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
👋 Hallo ${name}! <div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</h1> </div>
<p style="color: #d1fae5; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;"> <div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
Vielen Dank für deine Nachricht ${safeMsg}
</p> </div>
</div> </div>
<!-- Content --> <div style="margin-top:20px;text-align:center;">
<div style="padding: 40px 30px;"> <a href="${BRAND.siteUrl}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Portfolio ansehen
<!-- Welcome Message --> </a>
<div style="background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #bbf7d0;"> </div>
<div style="text-align: center; margin-bottom: 20px;"> `.trim(),
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;"> });
<span style="color: #ffffff; font-size: 24px;">✓</span> },
</div>
<h2 style="color: #065f46; margin: 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
</div>
<p style="color: #047857; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Nachricht! Ich habe sie erhalten und werde mich so schnell wie möglich bei dir melden.
</p>
</div>
<!-- Original Message Reference -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
Deine ursprüngliche Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Next Steps -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
🚀 Was passiert als nächstes?
</h3>
<div style="display: grid; gap: 15px;">
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">📧</span>
<div>
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Schnelle Antwort</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich antworte normalerweise innerhalb von 24 Stunden</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">💼</span>
<div>
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Projekt-Diskussion</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Gerne besprechen wir dein Projekt im Detail</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🤝</span>
<div>
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Zusammenarbeit</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Lass uns gemeinsam etwas Großartiges schaffen!</p>
</div>
</div>
</div>
</div>
<!-- Portfolio Links -->
<div style="text-align: center; margin-top: 30px;">
<h3 style="color: #374151; margin: 0 0 20px 0; font-size: 18px; font-weight: 600;">Entdecke mehr von mir</h3>
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
<a href="https://dk0.dev" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
🌐 Portfolio
</a>
<a href="https://github.com/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #374151 0%, #111827 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
💻 GitHub
</a>
<a href="https://linkedin.com/in/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #0077b5 0%, #005885 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
💼 LinkedIn
</a>
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dk0.dev" style="color: #10b981; text-decoration: none; font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-weight: bold;">dk<span style="color: #ef4444;">0</span>.dev</a> •
<a href="mailto:contact@dk0.dev" style="color: #10b981; text-decoration: none;">contact@dk0.dev</a>
</p>
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</body>
</html>
`
}, },
project: { project: {
subject: "Projekt-Anfrage erhalten! 🚀", subject: "Projekt-Anfrage erhalten! 🚀",
template: (name: string, originalMessage: string) => ` template: (name: string, originalMessage: string) => {
<!DOCTYPE html> const safeName = escapeHtml(name);
<html lang="de"> const safeMsg = nl2br(escapeHtml(originalMessage));
<head> return baseEmail({
<meta charset="UTF-8"> title: `Projekt-Anfrage: danke, ${safeName}!`,
<meta name="viewport" content="width=device-width, initial-scale=1.0"> subtitle: "Ich melde mich zeitnah",
<title>Projekt-Anfrage - Dennis Konkol</title> bodyHtml: `
</head> <div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;"> Hey ${safeName},<br><br>
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);"> mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
</div>
<!-- Header -->
<div style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); padding: 40px 30px; text-align: center;"> <div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;"> <div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
🚀 Projekt-Anfrage erhalten! <div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Projekt-Nachricht</div>
</h1> </div>
<p style="color: #e9d5ff; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;"> <div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
Hallo ${name}, lass uns etwas Großartiges schaffen! ${safeMsg}
</p> </div>
</div> </div>
<!-- Content --> <div style="margin-top:20px;text-align:center;">
<div style="padding: 40px 30px;"> <a href="mailto:${BRAND.email}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Kontakt aufnehmen
<!-- Project Message --> </a>
<div style="background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e9d5ff;"> </div>
<div style="text-align: center; margin-bottom: 20px;"> `.trim(),
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;"> });
<span style="color: #ffffff; font-size: 24px;">💼</span> },
</div>
<h2 style="color: #6b21a8; margin: 0; font-size: 22px; font-weight: 600;">Bereit für dein Projekt!</h2>
</div>
<p style="color: #7c2d12; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Projekt-Anfrage! Ich bin gespannt darauf, mehr über deine Ideen zu erfahren und wie wir sie gemeinsam umsetzen können.
</p>
</div>
<!-- Original Message -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 10px;"></span>
Deine Projekt-Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Process Steps -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
🎯 Mein Arbeitsprozess
</h3>
<div style="display: grid; gap: 15px;">
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">💬</span>
<div>
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">1. Erstgespräch</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Wir besprechen deine Anforderungen im Detail</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">📋</span>
<div>
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">2. Konzept & Planung</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich erstelle ein detailliertes Konzept für dein Projekt</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #10b981;">
<span style="color: #10b981; font-size: 20px; margin-right: 15px;">⚡</span>
<div>
<h4 style="color: #059669; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">3. Entwicklung</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Agile Entwicklung mit regelmäßigen Updates</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🎉</span>
<div>
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">4. Launch & Support</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Deployment und kontinuierlicher Support</p>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div style="text-align: center; margin-top: 30px;">
<a href="mailto:contact@dk0.dev?subject=Projekt-Diskussion mit ${name}" style="display: inline-block; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
💬 Projekt besprechen
</a>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dki.one" style="color: #8b5cf6; text-decoration: none;">dki.one</a> •
<a href="mailto:contact@dk0.dev" style="color: #8b5cf6; text-decoration: none;">contact@dk0.dev</a>
</p>
</div>
</div>
</body>
</html>
`
}, },
quick: { quick: {
subject: "Danke für deine Nachricht! ⚡", subject: "Danke für deine Nachricht! ⚡",
template: (name: string, originalMessage: string) => ` template: (name: string, originalMessage: string) => {
<!DOCTYPE html> const safeName = escapeHtml(name);
<html lang="de"> const safeMsg = nl2br(escapeHtml(originalMessage));
<head> return baseEmail({
<meta charset="UTF-8"> title: `Danke, ${safeName}!`,
<meta name="viewport" content="width=device-width, initial-scale=1.0"> subtitle: "Kurze Bestätigung",
<title>Quick Response - Dennis Konkol</title> bodyHtml: `
</head> <div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;"> Hey ${safeName},<br><br>
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);"> kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
</div>
<!-- Header -->
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 40px 30px; text-align: center;"> <div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;"> <div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
⚡ Schnelle Antwort! <div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</h1> </div>
<p style="color: #fef3c7; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;"> <div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
Hallo ${name}, danke für deine Nachricht! ${safeMsg}
</p> </div>
</div> </div>
`.trim(),
<!-- Content --> });
<div style="padding: 40px 30px;"> },
<!-- Quick Response -->
<div style="background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #fde68a;">
<div style="text-align: center;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">⚡</span>
</div>
<h2 style="color: #92400e; margin: 0 0 15px 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
<p style="color: #a16207; margin: 0; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Nachricht! Ich werde mich so schnell wie möglich bei dir melden.
</p>
</div>
</div>
<!-- Original Message -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 10px;"></span>
Deine Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #f59e0b;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Quick Info -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 25px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; text-align: center;">
📞 Kontakt
</h3>
<p style="color: #1e40af; margin: 0; text-align: center; line-height: 1.6; font-size: 14px;">
<strong>E-Mail:</strong> <a href="mailto:contact@dk0.dev" style="color: #1e40af; text-decoration: none;">contact@dk0.dev</a><br>
<strong>Portfolio:</strong> <a href="https://dki.one" style="color: #1e40af; text-decoration: none;">dki.one</a>
</p>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dki.one" style="color: #f59e0b; text-decoration: none;">dki.one</a>
</p>
</div>
</div>
</body>
</html>
`
}, },
reply: { reply: {
subject: "Antwort auf deine Nachricht 📧", subject: "Antwort auf deine Nachricht 📧",
template: (name: string, originalMessage: string) => ` template: (name: string, originalMessage: string) => {
<!DOCTYPE html> const safeName = escapeHtml(name);
<html lang="de"> const safeMsg = nl2br(escapeHtml(originalMessage));
<head> return baseEmail({
<meta charset="UTF-8"> title: `Antwort für ${safeName}`,
<meta name="viewport" content="width=device-width, initial-scale=1.0"> subtitle: "Neue Nachricht",
<title>Antwort - Dennis Konkol</title> bodyHtml: `
</head> <div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;"> Hey ${safeName},<br><br>
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);"> hier ist meine Antwort:
</div>
<!-- Header -->
<div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); padding: 40px 30px; text-align: center;"> <div style="margin-top:14px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;"> <div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
📧 Hallo ${name}! <div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Antwort</div>
</h1> </div>
<p style="color: #dbeafe; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;"> <div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
Hier ist meine Antwort auf deine Nachricht ${safeMsg}
</p> </div>
</div> </div>
`.trim(),
<!-- Content --> });
<div style="padding: 40px 30px;"> },
},
<!-- Reply Message -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #93c5fd;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">💬</span>
</div>
<h2 style="color: #1e40af; margin: 0; font-size: 22px; font-weight: 600;">Meine Antwort</h2>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="color: #1e40af; margin: 0; line-height: 1.6; font-size: 16px; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Original Message Reference -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
Deine ursprüngliche Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Contact Info -->
<div style="background: #f8fafc; padding: 25px; border-radius: 12px; text-align: center; border: 1px solid #e2e8f0;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 18px; font-weight: 600;">Weitere Fragen?</h3>
<p style="color: #6b7280; margin: 0 0 20px 0; line-height: 1.6;">
Falls du weitere Fragen hast oder mehr über meine Projekte erfahren möchtest, zögere nicht, mir zu schreiben!
</p>
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap;">
<a href="https://dki.one" style="display: inline-flex; align-items: center; padding: 12px 24px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500; transition: all 0.2s;">
🌐 Portfolio besuchen
</a>
<a href="mailto:contact@dk0.dev" style="display: inline-flex; align-items: center; padding: 12px 24px; background: #ffffff; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: 500; border: 2px solid #3b82f6; transition: all 0.2s;">
📧 Direkt antworten
</a>
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="color: #6b7280; margin: 0 0 10px 0; font-size: 14px; font-weight: 500;">
<strong>Dennis Konkol</strong> • <a href="https://dki.one" style="color: #3b82f6; text-decoration: none;">dki.one</a>
</p>
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</body>
</html>
`
}
}; };
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {

View File

@@ -15,6 +15,15 @@ function sanitizeInput(input: string, maxLength: number = 10000): string {
.trim(); .trim();
} }
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Rate limiting (defensive: headers may be undefined in tests) // Rate limiting (defensive: headers may be undefined in tests)
@@ -155,6 +164,22 @@ export async function POST(request: NextRequest) {
} }
} }
const brandUrl = "https://dk0.dev";
const sentAt = new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const safeName = escapeHtml(name);
const safeEmail = escapeHtml(email);
const safeSubject = escapeHtml(subject);
const safeMessageHtml = escapeHtml(message).replace(/\n/g, "<br>");
const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
const mailOptions: Mail.Options = { const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`, from: `"Portfolio Contact" <${user}>`,
to: "contact@dk0.dev", // Send to your contact email to: "contact@dk0.dev", // Send to your contact email
@@ -168,86 +193,80 @@ export async function POST(request: NextRequest) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neue Kontaktanfrage - Portfolio</title> <title>Neue Kontaktanfrage - Portfolio</title>
</head> </head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;"> <body style="margin:0;padding:0;background-color:#fdfcf8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#292524;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);"> <div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid #e7e5e4;border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<!-- Header --> <!-- Top bar -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;"> <div style="background:#292524;padding:22px 26px;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;"> <div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
📧 Neue Kontaktanfrage <div style="font-weight:700;font-size:16px;letter-spacing:-0.01em;color:#fdfcf8;">
</h1> Dennis Konkol
<p style="color: #e2e8f0; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;"> </div>
Von deinem Portfolio <div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:700;font-size:14px;color:#fdfcf8;">
</p> dk<span style="color:#ef4444;">0</span>.dev
</div>
</div> </div>
<div style="margin-top:10px;">
<!-- Content --> <div style="font-size:22px;font-weight:800;letter-spacing:-0.02em;color:#fdfcf8;">
<div style="padding: 40px 30px;"> Neue Kontaktanfrage
</div>
<!-- Contact Info Card --> <div style="margin-top:4px;font-size:13px;color:#d6d3d1;">
<div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e2e8f0;"> Eingegangen am ${sentAt}
<div style="display: flex; align-items: center; margin-bottom: 20px;"> </div>
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px;">
<span style="color: #ffffff; font-size: 20px; font-weight: bold;">${name.charAt(0).toUpperCase()}</span>
</div>
<div>
<h2 style="color: #1e293b; margin: 0; font-size: 24px; font-weight: 600;">${name}</h2>
<p style="color: #64748b; margin: 4px 0 0 0; font-size: 14px;">Kontaktanfrage</p>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<h4 style="color: #059669; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">E-Mail</h4>
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${email}</p>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<h4 style="color: #2563eb; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Betreff</h4>
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${subject}</p>
</div>
</div>
</div>
<!-- Message Card -->
<div style="background: #ffffff; padding: 30px; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 8px; height: 8px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; margin-right: 12px;"></div>
<h3 style="color: #1e293b; margin: 0; font-size: 18px; font-weight: 600;">Nachricht</h3>
</div>
<div style="background: #f8fafc; padding: 25px; border-radius: 8px; border-left: 4px solid #667eea;">
<p style="color: #374151; margin: 0; line-height: 1.7; font-size: 16px; white-space: pre-wrap;">${message}</p>
</div>
</div>
<!-- Action Button -->
<div style="text-align: center; margin-top: 30px;">
<a href="mailto:${email}?subject=Re: ${subject}" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transition: all 0.2s;">
📬 Antworten
</a>
</div>
</div> </div>
<div style="height:3px;background:#a7f3d0;margin-top:18px;border-radius:999px;"></div>
<!-- Footer --> </div>
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e2e8f0;">
<div style="margin-bottom: 15px;"> <!-- Content -->
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 1px;"></span> <div style="padding:26px;">
<!-- Sender -->
<div style="display:flex;align-items:flex-start;gap:14px;">
<div style="width:44px;height:44px;border-radius:14px;background:#f3f1e7;border:1px solid #e7e5e4;display:flex;align-items:center;justify-content:center;font-weight:800;color:#292524;">
${escapeHtml(initial)}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#292524;line-height:1.2;">
${safeName}
</div> </div>
<p style="color: #64748b; margin: 0; font-size: 14px; line-height: 1.5;"> <div style="margin-top:6px;font-size:13px;color:#78716c;line-height:1.4;">
Diese E-Mail wurde automatisch von deinem Portfolio generiert.<br> <span style="font-weight:700;color:#44403c;">E-Mail:</span> ${safeEmail}<br>
<strong>Dennis Konkol Portfolio</strong> • <a href="https://dki.one" style="color: #667eea; text-decoration: none;">dki.one</a> <span style="font-weight:700;color:#44403c;">Betreff:</span> ${safeSubject}
</p> </div>
<p style="color: #94a3b8; margin: 10px 0 0 0; font-size: 12px;"> </div>
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div> </div>
<!-- Message -->
<div style="margin-top:18px;background:#fdfcf8;border:1px solid #e7e5e4;border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:#f3f1e7;border-bottom:1px solid #e7e5e4;">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">
Nachricht
</div>
</div>
<div style="padding:16px;line-height:1.65;color:#292524;font-size:15px;border-left:4px solid #a7f3d0;">
${safeMessageHtml}
</div>
</div>
<!-- CTA -->
<div style="margin-top:22px;text-align:center;">
<a href="${escapeHtml(replyHref)}"
style="display:inline-block;background:#292524;color:#fdfcf8;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Antworten
</a>
<div style="margin-top:10px;font-size:12px;color:#78716c;">
Oder antworte direkt auf diese E-Mail.
</div>
</div>
</div>
<!-- Footer -->
<div style="padding:18px 26px;background:#fdfcf8;border-top:1px solid #e7e5e4;">
<div style="font-size:12px;color:#78716c;line-height:1.5;">
Automatisch generiert von <a href="${brandUrl}" style="color:#292524;text-decoration:underline;">dk0.dev</a>
</div>
</div>
</div> </div>
</div>
</body> </body>
</html> </html>
`, `,
@@ -261,7 +280,7 @@ Nachricht:
${message} ${message}
--- ---
Diese E-Mail wurde automatisch von deinem Portfolio generiert. Diese E-Mail wurde automatisch von dk0.dev generiert.
`, `,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,17 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import React, { useEffect, useState } from "react";
import React from "react"; import BackgroundBlobs from "@/components/BackgroundBlobs";
// Dynamically import the heavy framer-motion component on the client only
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs"), { ssr: false });
export default function BackgroundBlobsClient() { export default function BackgroundBlobsClient() {
// Avoid SSR/webpack bailout issues from `next/dynamic({ ssr:false })`
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <BackgroundBlobs />; return <BackgroundBlobs />;
} }

View File

@@ -20,21 +20,47 @@ interface Message {
} }
export default function ChatWidget() { export default function ChatWidget() {
// Prevent hydration mismatch by only rendering after mount
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState(() => { const [conversationId, setConversationId] = useState<string>("default");
// Generate or retrieve conversation ID
if (typeof window !== "undefined") { useEffect(() => {
setMounted(true);
// Generate or retrieve conversation ID only on client
try {
const stored = localStorage.getItem("chatSessionId"); const stored = localStorage.getItem("chatSessionId");
if (stored) return stored; if (stored) {
const newId = crypto.randomUUID(); setConversationId(stored);
return;
}
// Generate UUID with fallback for browsers without crypto.randomUUID
let newId: string;
if (typeof crypto !== "undefined" && crypto.randomUUID) {
newId = crypto.randomUUID();
} else {
// Fallback UUID generation
newId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
localStorage.setItem("chatSessionId", newId); localStorage.setItem("chatSessionId", newId);
return newId; setConversationId(newId);
} catch (error) {
// localStorage might be disabled or full
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to access localStorage for chat session:', error);
}
setConversationId(`session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
} }
return "default"; }, []);
});
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -53,8 +79,8 @@ export default function ChatWidget() {
// Helper function to decode HTML entities // Helper function to decode HTML entities
const decodeHtmlEntities = (text: string): string => { const decodeHtmlEntities = (text: string): string => {
if (!text || typeof text !== 'string') return text; if (!text || typeof text !== "string") return text;
const textarea = document.createElement('textarea'); const textarea = document.createElement("textarea");
textarea.innerHTML = text; textarea.innerHTML = text;
return textarea.value; return textarea.value;
}; };
@@ -62,22 +88,55 @@ export default function ChatWidget() {
// Load messages from localStorage // Load messages from localStorage
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem("chatMessages"); try {
if (stored) { const stored = localStorage.getItem("chatMessages");
try { if (stored) {
const parsed = JSON.parse(stored); try {
setMessages( const parsed = JSON.parse(stored);
parsed.map((m: Message) => ({ setMessages(
...m, parsed.map((m: Message) => ({
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading ...m,
timestamp: new Date(m.timestamp), text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
})), timestamp: new Date(m.timestamp),
); })),
} catch (e) { );
console.error("Failed to load chat history", e); } catch (e) {
if (process.env.NODE_ENV === 'development') {
console.error("Failed to parse chat history", e);
}
// Clear corrupted data
try {
localStorage.removeItem("chatMessages");
} catch {
// Ignore cleanup errors
}
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
} else {
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
} }
} else { } catch (error) {
// Add welcome message // localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to load chat history from localStorage:", error);
}
// Add welcome message anyway
setMessages([ setMessages([
{ {
id: "welcome", id: "welcome",
@@ -93,7 +152,14 @@ export default function ChatWidget() {
// Save messages to localStorage // Save messages to localStorage
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) { if (typeof window !== "undefined" && messages.length > 0) {
localStorage.setItem("chatMessages", JSON.stringify(messages)); try {
localStorage.setItem("chatMessages", JSON.stringify(messages));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to save chat messages to localStorage:", error);
}
}
} }
}, [messages]); }, [messages]);
@@ -129,25 +195,28 @@ export default function ChatWidget() {
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error'); const errorText = await response.text().catch(() => "Unknown error");
console.error("Chat API error:", { console.error("Chat API error:", {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
error: errorText, error: errorText,
}); });
throw new Error(`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`); throw new Error(
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
);
} }
const data = await response.json(); const data = await response.json();
// Log response for debugging (only in development) // Log response for debugging (only in development)
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.log("Chat API response:", data); console.log("Chat API response:", data);
} }
// Decode HTML entities in the reply // Decode HTML entities in the reply
let replyText = data.reply || "Sorry, I couldn't process that. Please try again."; let replyText =
data.reply || "Sorry, I couldn't process that. Please try again.";
// Decode HTML entities client-side (double safety) // Decode HTML entities client-side (double safety)
replyText = decodeHtmlEntities(replyText); replyText = decodeHtmlEntities(replyText);
@@ -201,6 +270,11 @@ export default function ChatWidget() {
]); ]);
}; };
// Don't render until mounted to prevent hydration mismatch
if (!mounted) {
return null;
}
return ( return (
<> <>
{/* Chat Button */} {/* Chat Button */}
@@ -218,15 +292,15 @@ export default function ChatWidget() {
setIsOpen(true); setIsOpen(true);
} }
}} }}
className="fixed bottom-20 left-4 md:bottom-6 md:left-6 z-30 bg-gradient-to-br from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-2xl hover:shadow-blue-500/50 hover:scale-110 transition-all duration-300 group cursor-pointer" className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-[#292524] text-[#fdfcf8] p-3.5 rounded-full shadow-[0_8px_20px_rgba(41,37,36,0.25)] hover:bg-[#44403c] hover:scale-105 transition-all duration-300 group cursor-pointer border border-[#f3f1e7]/20 ring-1 ring-[#f3f1e7]/10"
aria-label="Open chat" aria-label="Open chat"
> >
<MessageCircle size={20} /> <MessageCircle size={24} />
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" /> <span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-[#292524]" />
{/* Tooltip */} {/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5 bg-black/90 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-[100] shadow-lg"> <span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
Chat with AI assistant Chat with AI
</span> </span>
</motion.div> </motion.div>
)} )}
@@ -236,40 +310,43 @@ export default function ChatWidget() {
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }} data-chat-widget
animate={{ opacity: 1, y: 0, scale: 1 }} initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
exit={{ opacity: 0, y: 20, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }} exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
className="fixed bottom-20 left-4 md:bottom-6 md:left-6 z-30 w-[300px] sm:w-[340px] md:w-[380px] max-w-[calc(100vw-2rem)] h-[450px] sm:h-[500px] md:h-[550px] max-h-[calc(100vh-10rem)] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col overflow-hidden border border-gray-200 dark:border-gray-800" transition={{ type: "spring", damping: 30, stiffness: 400 }}
className="fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-[#fdfcf8]/95 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.2)] flex flex-col overflow-hidden border border-[#e7e5e4] ring-1 ring-[#f3f1e7]"
> >
{/* Header */} {/* Header */}
<div className="bg-gradient-to-br from-blue-500 to-purple-600 text-white p-3 md:p-4 flex items-center justify-between"> <div className="bg-[#fdfcf8] text-[#292524] p-4 flex items-center justify-between border-b border-[#e7e5e4]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#f3f1e7] to-[#fdfcf8] flex items-center justify-center ring-1 ring-[#e7e5e4] shadow-sm">
<Sparkles size={20} /> <Sparkles size={18} className="text-[#57534e]" />
</div> </div>
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-white" /> <span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-[#fdfcf8] shadow-sm" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-bold text-sm truncate"> <h3 className="font-bold text-sm truncate text-stone-900 tracking-tight">
Dennis{'\''}s AI Assistant Assistant
</h3> </h3>
<p className="text-xs text-white/80 truncate">Always online</p> <p className="text-[11px] font-medium text-stone-500 truncate">
Powered by AI
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<button <button
onClick={clearChat} onClick={clearChat}
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-white/80 hover:text-white" className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-red-500"
title="Clear conversation" title="Clear conversation"
> >
<Trash2 size={18} /> <Trash2 size={16} />
</button> </button>
<button <button
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-stone-900"
aria-label="Close chat" aria-label="Close chat"
> >
<X size={20} /> <X size={20} />
@@ -278,7 +355,7 @@ export default function ChatWidget() {
</div> </div>
{/* Messages */} {/* Messages */}
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-3 md:space-y-4 bg-gray-50 dark:bg-gray-950"> <div className="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-4 bg-transparent">
{messages.map((message) => ( {messages.map((message) => (
<motion.div <motion.div
key={message.id} key={message.id}
@@ -287,20 +364,22 @@ export default function ChatWidget() {
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`} className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
> >
<div <div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${ className={`max-w-[85%] rounded-2xl px-4 py-3 shadow-sm ${
message.sender === "user" message.sender === "user"
? "bg-gradient-to-br from-blue-500 to-purple-600 text-white" ? "bg-[#292524] text-[#fdfcf8]"
: "bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700" : "bg-[#f3f1e7] text-[#292524] border border-[#e7e5e4]"
}`} }`}
> >
<p className="text-sm whitespace-pre-wrap break-words"> <p className={`text-sm whitespace-pre-wrap break-words leading-relaxed ${
message.sender === "user" ? "text-[#fdfcf8]/90 font-light" : "text-[#292524] font-medium"
}`}>
{message.text} {message.text}
</p> </p>
<p <p
className={`text-[10px] mt-1 ${ className={`text-[10px] mt-1.5 ${
message.sender === "user" message.sender === "user"
? "text-white/60" ? "text-stone-400"
: "text-gray-500 dark:text-gray-400" : "text-stone-500"
}`} }`}
> >
{message.timestamp.toLocaleTimeString([], { {message.timestamp.toLocaleTimeString([], {
@@ -319,11 +398,11 @@ export default function ChatWidget() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="flex justify-start" className="flex justify-start"
> >
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl px-4 py-3"> <div className="bg-[#f3f1e7] border border-[#e7e5e4] rounded-2xl px-4 py-3 shadow-sm">
<div className="flex gap-1"> <div className="flex gap-1.5">
<motion.div <motion.div
className="w-2 h-2 bg-gray-400 rounded-full" className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -8, 0] }} animate={{ y: [0, -6, 0] }}
transition={{ transition={{
duration: 0.6, duration: 0.6,
repeat: Infinity, repeat: Infinity,
@@ -331,8 +410,8 @@ export default function ChatWidget() {
}} }}
/> />
<motion.div <motion.div
className="w-2 h-2 bg-gray-400 rounded-full" className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -8, 0] }} animate={{ y: [0, -6, 0] }}
transition={{ transition={{
duration: 0.6, duration: 0.6,
repeat: Infinity, repeat: Infinity,
@@ -340,8 +419,8 @@ export default function ChatWidget() {
}} }}
/> />
<motion.div <motion.div
className="w-2 h-2 bg-gray-400 rounded-full" className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -8, 0] }} animate={{ y: [0, -6, 0] }}
transition={{ transition={{
duration: 0.6, duration: 0.6,
repeat: Infinity, repeat: Infinity,
@@ -357,7 +436,7 @@ export default function ChatWidget() {
</div> </div>
{/* Input */} {/* Input */}
<div className="p-3 md:p-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800"> <div className="p-4 bg-[#fdfcf8] border-t border-[#e7e5e4]">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
ref={inputRef} ref={inputRef}
@@ -367,37 +446,37 @@ export default function ChatWidget() {
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
placeholder="Ask anything..." placeholder="Ask anything..."
disabled={isLoading} disabled={isLoading}
className="flex-1 px-3 md:px-4 py-2 text-sm bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-full border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-[#fdfcf8] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-all shadow-inner"
/> />
<button <button
onClick={handleSend} onClick={handleSend}
disabled={!inputValue.trim() || isLoading} disabled={!inputValue.trim() || isLoading}
className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-full hover:shadow-lg hover:scale-110 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 shadow-md flex items-center justify-center aspect-square"
aria-label="Send message" aria-label="Send message"
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
) : ( ) : (
<Send size={20} /> <Send size={18} />
)} )}
</button> </button>
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="flex gap-2 mt-2 overflow-x-auto pb-1 scrollbar-hide"> <div className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-hide mask-fade-right">
{[ {[
"What are Dennis's skills?", "Skills 🛠️",
"Tell me about his projects", "Projects 🚀",
"How can I contact him?", "Contact 📧",
].map((suggestion, index) => ( ].map((suggestion, index) => (
<button <button
key={index} key={index}
onClick={() => { onClick={() => {
setInputValue(suggestion); setInputValue(suggestion.replace(/ .*/, '')); // Strip emoji for search if needed, or keep
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
disabled={isLoading} disabled={isLoading}
className="px-2 md:px-3 py-1 text-[10px] md:text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors whitespace-nowrap disabled:opacity-50 flex-shrink-0" className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-all whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
> >
{suggestion} {suggestion}
</button> </button>

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export function ClientOnly({ children }: { children: React.ReactNode }) { export default function ClientOnly({ children }: { children: React.ReactNode }) {
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,85 @@
"use client";
import React, { useEffect, useState } from "react";
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";
// Dynamic import with SSR disabled to avoid framer-motion issues
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
export default function ClientProviders({
children,
}: {
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
const [is404Page, setIs404Page] = useState(false);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
// Check if we're on a 404 page by looking for the data attribute or pathname
const check404 = () => {
try {
if (typeof window !== "undefined" && typeof document !== "undefined") {
const has404Component = document.querySelector('[data-404-page]');
const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
setIs404Page(!!has404Component || is404Path);
}
} catch (error) {
// Silently fail - 404 detection is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error checking 404 status:', error);
}
}
};
// Check immediately and after a short delay
try {
check404();
const timeout = setTimeout(check404, 100);
const interval = setInterval(check404, 500);
return () => {
try {
clearTimeout(timeout);
clearInterval(interval);
} catch {
// Silently fail during cleanup
}
};
} catch (error) {
// If setup fails, just return empty cleanup
if (process.env.NODE_ENV === 'development') {
console.warn('Error setting up 404 check:', error);
}
return () => {};
}
}, [pathname]);
// Wrap in multiple error boundaries to isolate failures
return (
<ErrorBoundary>
<ErrorBoundary>
<AnalyticsProvider>
<ErrorBoundary>
<ToastProvider>
{mounted && <BackgroundBlobs />}
<div className="relative z-10">{children}</div>
{mounted && !is404Page && <ChatWidget />}
</ToastProvider>
</ErrorBoundary>
</AnalyticsProvider>
</ErrorBoundary>
</ErrorBoundary>
);
}

View File

@@ -80,9 +80,9 @@ const Header = () => {
> >
<Link <Link
href="/" href="/"
className="text-2xl font-bold font-mono text-stone-800 tracking-tighter liquid-hover" className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
> >
dk<span className="text-liquid-rose">0</span> dk<span className="text-red-500">0</span>
</Link> </Link>
</motion.div> </motion.div>

View File

@@ -103,8 +103,8 @@ const Hero = () => {
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }} transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30" className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
> >
<div className="px-6 py-2.5 rounded-full glass-panel text-stone-700 font-mono text-sm tracking-wider shadow-lg backdrop-blur-xl border border-white/50"> <div className="px-6 py-2.5 rounded-full glass-panel text-stone-800 font-sans font-bold text-sm tracking-wide shadow-lg backdrop-blur-xl border border-white/50">
dk<span className="text-liquid-rose font-bold">0</span>.dev dk<span className="text-red-500 font-extrabold">0</span>.dev
</div> </div>
</motion.div> </motion.div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
export default function KernelPanic404Wrapper() {
useEffect(() => {
// Ensure body and html don't interfere
document.body.style.background = "#020202";
document.body.style.color = "#33ff00";
document.documentElement.style.background = "#020202";
document.documentElement.style.color = "#33ff00";
return () => {
// Cleanup
document.body.style.background = "";
document.body.style.color = "";
document.documentElement.style.background = "";
document.documentElement.style.color = "";
};
}, []);
return (
<iframe
src="/404-terminal.html"
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
border: "none",
zIndex: 9999,
margin: 0,
padding: 0,
backgroundColor: "#020202",
}}
data-404-page="true"
allowTransparency={false}
/>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion, Variants } from "framer-motion"; import { motion, Variants } from "framer-motion";
import { ExternalLink, Github, Layers, ArrowRight } from "lucide-react"; import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@@ -98,50 +98,72 @@ const Projects = () => {
<motion.div <motion.div
key={project.id} key={project.id}
variants={fadeInUp} variants={fadeInUp}
whileHover={{ whileHover={{ y: -8 }}
y: -12, className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
transition: { duration: 0.5, ease: "easeOut" },
}}
className="group relative flex flex-col bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-700 ease-out border border-stone-100 hover:border-stone-200"
> >
{/* Project Cover / Header */} {/* Project Cover / Image Area */}
<div className="relative aspect-[4/3] overflow-hidden bg-gradient-to-br from-stone-50 to-stone-100"> <div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? ( {project.imageUrl ? (
<Image <>
src={project.imageUrl} <Image
alt={project.title} src={project.imageUrl}
fill alt={project.title}
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110" fill
/> className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-stone-100 to-stone-200 flex items-center justify-center p-8 group-hover:from-stone-50 group-hover:to-stone-100 transition-colors duration-700 ease-out"> <div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
<div className="w-full h-full border-2 border-dashed border-stone-300 rounded-xl flex items-center justify-center"> <div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
<Layers className="text-stone-300 w-12 h-12" /> <div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
</span>
</div>
</div>
)}
{/* Texture/Grain Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{/* Featured Badge */}
{project.featured && (
<div className="absolute top-3 left-3 z-20">
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
Featured
</div> </div>
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint/10 via-transparent to-liquid-rose/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</div> </div>
)} )}
{/* Overlay Links */} {/* Overlay Links */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-700 ease-out flex items-center justify-center gap-4 backdrop-blur-sm"> <div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && ( {project.github && (
<a <a
href={project.github} href={project.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-all duration-500 ease-out hover:shadow-lg" className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="GitHub" aria-label="GitHub"
onClick={(e) => e.stopPropagation()}
> >
<Github size={20} /> <Github size={20} />
</a> </a>
)} )}
{project.live && ( {project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a <a
href={project.live} href={project.live}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-all duration-500 ease-out hover:shadow-lg" className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="Live Demo" aria-label="Live Demo"
onClick={(e) => e.stopPropagation()}
> >
<ExternalLink size={20} /> <ExternalLink size={20} />
</a> </a>
@@ -150,47 +172,67 @@ const Projects = () => {
</div> </div>
{/* Content */} {/* Content */}
<div className="flex flex-col flex-1 p-6"> <div className="p-6 flex flex-col flex-1">
<div className="flex justify-between items-start mb-3"> {/* Stretched Link covering the whole card (including image area) */}
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-700 transition-colors duration-500"> <Link
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title} {project.title}
</h3> </h3>
<span className="text-xs font-mono text-stone-400 bg-stone-100 px-2 py-1 rounded"> <div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
{new Date(project.date).getFullYear()} <Calendar size={12} />
</span> <span>{new Date(project.date).getFullYear()}</span>
</div>
</div> </div>
<p className="text-stone-700 text-sm leading-relaxed mb-6 line-clamp-3 flex-1"> <p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
{project.description} {project.description}
</p> </p>
<div className="space-y-4 mt-auto"> <div className="flex flex-wrap gap-2 mb-6">
<div className="flex flex-wrap gap-2"> {project.tags.slice(0, 4).map((tag) => (
{project.tags.slice(0, 3).map((tag, tIdx) => ( <span
<span key={tag}
key={`${project.id}-${tag}-${tIdx}`} className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
className="text-xs px-2.5 py-1 bg-stone-50 border border-stone-100 rounded-md text-stone-600 font-medium hover:bg-stone-100 hover:border-stone-200 transition-all duration-400 ease-out" >
> {tag}
{tag} </span>
</span> ))}
))} {project.tags.length > 4 && (
{project.tags.length > 3 && ( <span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
<span className="text-xs px-2 py-1 text-stone-400"> )}
+ {project.tags.length - 3} </div>
</span>
)}
</div>
<Link <div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
href={`/projects/${project.title.toLowerCase().replace(/\s+/g, "-")}`} <div className="flex gap-3">
className="inline-flex items-center text-sm font-semibold text-stone-900 hover:gap-3 transition-all duration-500 ease-out group/link" {project.github && (
> <a
Read more{" "} href={project.github}
<ArrowRight target="_blank"
size={16} rel="noopener noreferrer"
className="ml-1 transition-transform duration-500 ease-out group-hover/link:translate-x-2" className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
/> onClick={(e) => e.stopPropagation()}
</Link> >
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@@ -0,0 +1,51 @@
"use client";
import React, { useEffect, useState } from "react";
// Lazy load providers to avoid webpack module resolution issues
const AnalyticsProvider = React.lazy(() =>
import("@/components/AnalyticsProvider").then((mod) => ({
default: mod.AnalyticsProvider,
}))
);
const ToastProvider = React.lazy(() =>
import("@/components/Toast").then((mod) => ({
default: mod.ToastProvider,
}))
);
const BackgroundBlobs = React.lazy(() =>
import("@/components/BackgroundBlobs")
);
const ChatWidget = React.lazy(() => import("./ChatWidget"));
export default function RootProviders({
children,
}: {
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="relative z-10">{children}</div>;
}
return (
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
<AnalyticsProvider>
<ToastProvider>
<BackgroundBlobs />
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</AnalyticsProvider>
</React.Suspense>
);
}

View File

@@ -29,6 +29,7 @@ import {
Github, Github,
Tag, Tag,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/components/Toast";
interface Project { interface Project {
id: string; id: string;
@@ -50,6 +51,7 @@ function EditorPageContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const projectId = searchParams.get("id"); const projectId = searchParams.get("id");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const { showSuccess, showError } = useToast();
const [, setProject] = useState<Project | null>(null); const [, setProject] = useState<Project | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -57,7 +59,11 @@ function EditorPageContent() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isCreating, setIsCreating] = useState(!projectId); const [isCreating, setIsCreating] = useState(!projectId);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [isTyping, setIsTyping] = useState(false); const [_isTyping, setIsTyping] = useState(false);
const [history, setHistory] = useState<typeof formData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [originalFormData, setOriginalFormData] = useState<typeof formData | null>(null);
const shouldUpdateContentRef = useRef(true);
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -84,8 +90,7 @@ function EditorPageContent() {
); );
if (foundProject) { if (foundProject) {
setProject(foundProject); const initialData = {
setFormData({
title: foundProject.title || "", title: foundProject.title || "",
description: foundProject.description || "", description: foundProject.description || "",
content: foundProject.content || "", content: foundProject.content || "",
@@ -96,7 +101,19 @@ function EditorPageContent() {
github: foundProject.github || "", github: foundProject.github || "",
live: foundProject.live || "", live: foundProject.live || "",
image: foundProject.image || "", 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 { } else {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
@@ -126,6 +143,30 @@ function EditorPageContent() {
await loadProject(projectId); await loadProject(projectId);
} else { } else {
setIsCreating(true); 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 { } else {
setIsAuthenticated(false); setIsAuthenticated(false);
@@ -143,18 +184,20 @@ function EditorPageContent() {
init(); init();
}, [projectId, loadProject]); }, [projectId, loadProject]);
const handleSave = async () => { const handleSave = useCallback(async () => {
try { try {
setIsSaving(true); setIsSaving(true);
// Validate required fields // Validate required fields
if (!formData.title.trim()) { if (!formData.title.trim()) {
alert("Please enter a project title"); showError("Validation Error", "Please enter a project title");
setIsSaving(false);
return; return;
} }
if (!formData.description.trim()) { if (!formData.description.trim()) {
alert("Please enter a project description"); showError("Validation Error", "Please enter a project description");
setIsSaving(false);
return; return;
} }
@@ -205,40 +248,156 @@ function EditorPageContent() {
image: savedProject.imageUrl || "", image: savedProject.imageUrl || "",
})); }));
// Show success and redirect // Show success toast (smaller, smoother)
alert("Project saved successfully!"); showSuccess("Saved", `"${savedProject.title}" saved`);
setTimeout(() => {
window.location.href = "/manage"; // Update project ID if it was a new project
}, 1000); if (!projectId && savedProject.id) {
const newUrl = `/editor?id=${savedProject.id}`;
window.history.replaceState({}, '', newUrl);
}
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.error("Error saving project:", response.status, errorData); 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) { } catch (error) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.error("Error saving project:", error); console.error("Error saving project:", error);
} }
alert( showError(
`Error saving project: ${error instanceof Error ? error.message : "Unknown error"}`, "Save Failed",
error instanceof Error ? error.message : "Failed to save"
); );
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; }, [projectId, formData, showSuccess, showError]);
const handleInputChange = ( const handleInputChange = (
field: string, field: string,
value: string | boolean | string[], value: string | boolean | string[],
) => { ) => {
setFormData((prev) => ({ setFormData((prev) => {
...prev, const newData = {
[field]: value, ...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 handleTagsChange = (tagsString: string) => {
const tags = tagsString const tags = tagsString
.split(",") .split(",")
@@ -358,7 +517,7 @@ function EditorPageContent() {
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-12 h-12 border-3 border-blue-500 border-t-transparent rounded-full mx-auto mb-6" className="w-12 h-12 border-3 border-stone-500 border-t-transparent rounded-full mx-auto mb-6"
/> />
<h2 className="text-xl font-semibold gradient-text mb-2"> <h2 className="text-xl font-semibold gradient-text mb-2">
Loading Editor Loading Editor
@@ -390,7 +549,7 @@ function EditorPageContent() {
<button <button
onClick={() => (window.location.href = "/manage")} onClick={() => (window.location.href = "/manage")}
className="w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl hover:scale-105 transition-all font-medium" className="w-full px-6 py-3 bg-stone-600 text-white rounded-xl hover:bg-stone-700 transition-all font-medium"
> >
Go to Admin Login Go to Admin Login
</button> </button>
@@ -400,15 +559,15 @@ function EditorPageContent() {
} }
return ( return (
<div className="min-h-screen animated-bg"> <div className="min-h-screen animated-bg flex flex-col overflow-hidden">
{/* Header */} {/* Header */}
<div className="glass-card border-b border-white/10 sticky top-0 z-50"> <div className="glass-card border-b border-white/10 sticky top-0 z-50 flex-shrink-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between h-auto sm:h-16 py-4 sm:py-0 gap-4 sm:gap-0"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between h-auto sm:h-16 py-4 sm:py-0 gap-4 sm:gap-0">
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-4"> <div className="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<button <button
onClick={() => (window.location.href = "/manage")} onClick={() => (window.location.href = "/manage")}
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors" className="inline-flex items-center space-x-2 text-stone-400 hover:text-stone-300 transition-colors"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
<span className="hidden sm:inline">Back to Dashboard</span> <span className="hidden sm:inline">Back to Dashboard</span>
@@ -427,8 +586,8 @@ function EditorPageContent() {
onClick={() => setShowPreview(!showPreview)} onClick={() => setShowPreview(!showPreview)}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 text-sm ${ className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 text-sm ${
showPreview showPreview
? "bg-blue-600 text-white shadow-lg" ? "bg-stone-600 text-white shadow-lg"
: "bg-gray-800/50 text-gray-300 hover:bg-gray-700/50 hover:text-white" : "bg-gray-800/50 text-stone-300 hover:bg-gray-700/50 hover:text-stone-100"
}`} }`}
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
@@ -452,9 +611,10 @@ function EditorPageContent() {
</div> </div>
</div> </div>
{/* Editor Content */} {/* Editor Content - Scrollable */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8"> <div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6 lg:gap-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
{/* Floating particles background */} {/* Floating particles background */}
<div className="particles"> <div className="particles">
{[...Array(20)].map((_, i) => ( {[...Array(20)].map((_, i) => (
@@ -469,187 +629,12 @@ function EditorPageContent() {
/> />
))} ))}
</div> </div>
{/* Main Editor */}
<div className="xl:col-span-3 space-y-6"> {/* Sidebar - Left (appears first in DOM for left positioning) */}
{/* Project Title */} <div className="w-full lg:w-80 flex-shrink-0 space-y-6 order-1 lg:order-1">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card p-6 rounded-2xl"
>
<input
type="text"
value={formData.title}
onChange={(e) => 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..."
/>
</motion.div>
{/* Rich Text Toolbar */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="glass-card p-4 rounded-2xl"
>
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button
onClick={() => insertFormatting("bold")}
className="p-2 rounded-lg text-gray-300"
title="Bold"
>
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("italic")}
className="p-2 rounded-lg text-gray-300"
title="Italic"
>
<Italic className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("code")}
className="p-2 rounded-lg text-gray-300"
title="Code"
>
<Code className="w-4 h-4" />
</button>
</div>
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button
onClick={() => insertFormatting("h1")}
className="p-2 rounded-lg text-gray-300"
title="Heading 1"
>
<Hash className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("h2")}
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-gray-300 hover:text-white hover:scale-105"
title="Heading 2"
>
H2
</button>
<button
onClick={() => insertFormatting("h3")}
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-gray-300 hover:text-white hover:scale-105"
title="Heading 3"
>
H3
</button>
</div>
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button
onClick={() => insertFormatting("list")}
className="p-2 rounded-lg text-gray-300"
title="Bullet List"
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("orderedList")}
className="p-2 rounded-lg text-gray-300"
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("quote")}
className="p-2 rounded-lg text-gray-300"
title="Quote"
>
<Quote className="w-4 h-4" />
</button>
</div>
<div className="flex items-center space-x-1">
<button
onClick={() => insertFormatting("link")}
className="p-2 rounded-lg text-gray-300"
title="Link"
>
<Link className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("image")}
className="p-2 rounded-lg text-gray-300"
title="Image"
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
{/* Content Editor */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="glass-card p-6 rounded-2xl"
>
<h3 className="text-lg font-semibold gradient-text mb-4">
Content
</h3>
<div
ref={contentRef}
contentEditable
className="editor-content-editable w-full min-h-[400px] p-6 form-input-enhanced rounded-lg focus:outline-none leading-relaxed"
style={{ whiteSpace: "pre-wrap" }}
onInput={(e) => {
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}
</div>
<p className="text-xs text-white/50 mt-2">
Supports Markdown formatting. Use the toolbar above or type
directly.
</p>
</motion.div>
{/* Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="glass-card p-6 rounded-2xl"
>
<h3 className="text-lg font-semibold gradient-text mb-4">
Description
</h3>
<textarea
value={formData.description}
onChange={(e) =>
handleInputChange("description", e.target.value)
}
rows={4}
className="w-full px-4 py-3 form-input-enhanced rounded-lg focus:outline-none resize-none"
placeholder="Brief description of your project..."
/>
</motion.div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Project Settings */} {/* Project Settings */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }} transition={{ delay: 0.4 }}
className="glass-card p-6 rounded-2xl" className="glass-card p-6 rounded-2xl"
@@ -660,7 +645,7 @@ function EditorPageContent() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-2"> <label className="block text-sm font-medium text-stone-300 mb-2">
Category Category
</label> </label>
<div className="custom-select"> <div className="custom-select">
@@ -681,7 +666,7 @@ function EditorPageContent() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-2"> <label className="block text-sm font-medium text-stone-300 mb-2">
Tags Tags
</label> </label>
<input <input
@@ -697,7 +682,7 @@ function EditorPageContent() {
{/* Links */} {/* Links */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="glass-card p-6 rounded-2xl" className="glass-card p-6 rounded-2xl"
@@ -708,7 +693,7 @@ function EditorPageContent() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-2"> <label className="block text-sm font-medium text-stone-300 mb-2">
GitHub URL GitHub URL
</label> </label>
<input <input
@@ -723,7 +708,7 @@ function EditorPageContent() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-2"> <label className="block text-sm font-medium text-stone-300 mb-2">
Live URL Live URL
</label> </label>
<input <input
@@ -739,7 +724,7 @@ function EditorPageContent() {
{/* Publish */} {/* Publish */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }} transition={{ delay: 0.6 }}
className="glass-card p-6 rounded-2xl" className="glass-card p-6 rounded-2xl"
@@ -756,9 +741,9 @@ function EditorPageContent() {
onChange={(e) => onChange={(e) =>
handleInputChange("featured", e.target.checked) handleInputChange("featured", e.target.checked)
} }
className="w-4 h-4 text-blue-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-blue-500 focus:ring-2" className="w-4 h-4 text-stone-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-stone-500 focus:ring-2"
/> />
<span className="text-white">Featured Project</span> <span className="text-stone-200">Featured Project</span>
</label> </label>
<label className="flex items-center space-x-3"> <label className="flex items-center space-x-3">
@@ -768,20 +753,20 @@ function EditorPageContent() {
onChange={(e) => onChange={(e) =>
handleInputChange("published", e.target.checked) handleInputChange("published", e.target.checked)
} }
className="w-4 h-4 text-blue-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-blue-500 focus:ring-2" className="w-4 h-4 text-stone-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-stone-500 focus:ring-2"
/> />
<span className="text-white">Published</span> <span className="text-stone-200">Published</span>
</label> </label>
</div> </div>
<div className="mt-6 pt-4 border-t border-white/20"> <div className="mt-6 pt-4 border-t border-white/20">
<h4 className="text-sm font-medium text-white/70 mb-2"> <h4 className="text-sm font-medium text-stone-300 mb-2">
Preview Preview
</h4> </h4>
<div className="text-xs text-white/50 space-y-1"> <div className="text-xs text-stone-400 space-y-1">
<p>Status: {formData.published ? "Published" : "Draft"}</p> <p>Status: {formData.published ? "Published" : "Draft"}</p>
{formData.featured && ( {formData.featured && (
<p className="text-blue-400"> Featured</p> <p className="text-stone-400"> Featured</p>
)} )}
<p>Category: {formData.category}</p> <p>Category: {formData.category}</p>
<p>Tags: {formData.tags.length} tags</p> <p>Tags: {formData.tags.length} tags</p>
@@ -789,8 +774,227 @@ function EditorPageContent() {
</div> </div>
</motion.div> </motion.div>
</div> </div>
{/* Main Editor - Right (appears second in DOM for right positioning) */}
<div className="flex-1 space-y-6 order-2 lg:order-2 min-w-0">
{/* Project Title */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card p-6 rounded-2xl"
>
<input
type="text"
value={formData.title}
onChange={(e) => 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..."
/>
</motion.div>
{/* Description - Under Title */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="glass-card p-6 rounded-2xl"
>
<h3 className="text-lg font-semibold gradient-text mb-4">
Description
</h3>
<textarea
value={formData.description}
onChange={(e) =>
handleInputChange("description", e.target.value)
}
rows={4}
className="w-full px-4 py-3 form-input-enhanced rounded-lg focus:outline-none resize-none"
placeholder="Brief description of your project..."
/>
</motion.div>
{/* Rich Text Toolbar */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="glass-card p-4 rounded-2xl"
>
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button
onClick={() => insertFormatting("bold")}
className="p-2 rounded-lg text-stone-300"
title="Bold"
>
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("italic")}
className="p-2 rounded-lg text-stone-300"
title="Italic"
>
<Italic className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("code")}
className="p-2 rounded-lg text-stone-300"
title="Code"
>
<Code className="w-4 h-4" />
</button>
</div>
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button
onClick={() => insertFormatting("h1")}
className="p-2 rounded-lg text-stone-300"
title="Heading 1"
>
<Hash className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("h2")}
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-stone-300 hover:text-stone-100"
title="Heading 2"
>
H2
</button>
<button
onClick={() => insertFormatting("h3")}
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-stone-300 hover:text-stone-100"
title="Heading 3"
>
H3
</button>
</div>
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button
onClick={() => insertFormatting("list")}
className="p-2 rounded-lg text-stone-300"
title="Bullet List"
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("orderedList")}
className="p-2 rounded-lg text-stone-300"
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("quote")}
className="p-2 rounded-lg text-stone-300"
title="Quote"
>
<Quote className="w-4 h-4" />
</button>
</div>
<div className="flex items-center space-x-1">
<button
onClick={() => insertFormatting("link")}
className="p-2 rounded-lg text-stone-300"
title="Link"
>
<Link className="w-4 h-4" />
</button>
<button
onClick={() => insertFormatting("image")}
className="p-2 rounded-lg text-stone-300"
title="Image"
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
{/* Content Editor */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="glass-card p-6 rounded-2xl"
>
<h3 className="text-lg font-semibold gradient-text mb-4">
Content
</h3>
<div className="relative">
<div
ref={contentRef}
contentEditable
className="editor-content-editable w-full min-h-[500px] p-6 form-input-enhanced rounded-lg focus:outline-none leading-relaxed"
style={{ whiteSpace: "pre-wrap" }}
onFocus={(e) => {
// Ensure content is set when focusing if empty
const target = e.target as HTMLDivElement;
const currentText = target.textContent || "";
if (!currentText && formData.content) {
target.textContent = formData.content;
} else if (currentText !== formData.content && shouldUpdateContentRef.current) {
// Sync if content changed externally (undo/redo)
target.textContent = formData.content;
}
shouldUpdateContentRef.current = false;
}}
onMouseDown={(e) => {
// Prevent content from being cleared on click
const target = e.target as HTMLDivElement;
const currentText = target.textContent || "";
if (!currentText && formData.content) {
target.textContent = formData.content;
}
shouldUpdateContentRef.current = false;
}}
onClick={(e) => {
// Ensure content persists on click
const target = e.target as HTMLDivElement;
if (!target.textContent && formData.content) {
target.textContent = formData.content;
}
}}
onInput={(e) => {
const target = e.target as HTMLDivElement;
setIsTyping(true);
shouldUpdateContentRef.current = false;
const newContent = target.textContent || "";
setFormData((prev) => {
const newData = {
...prev,
content: newContent,
};
// 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;
});
}}
onBlur={() => {
setIsTyping(false);
}}
suppressContentEditableWarning={true}
data-placeholder="Start writing your project content..."
/>
</div>
<p className="text-xs text-stone-400 mt-2">
Supports Markdown formatting. Use the toolbar above or type
directly.
</p>
</motion.div>
</div>
</div> </div>
</div> </div>
</div>
{/* Preview Modal */} {/* Preview Modal */}
<AnimatePresence> <AnimatePresence>
@@ -806,7 +1010,7 @@ function EditorPageContent() {
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }} exit={{ scale: 0.9, opacity: 0 }}
className="glass-card rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto" className="glass-card rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto scrollbar-hide"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -834,12 +1038,12 @@ function EditorPageContent() {
{/* Project Meta */} {/* Project Meta */}
<div className="flex flex-wrap justify-center gap-4 mb-6"> <div className="flex flex-wrap justify-center gap-4 mb-6">
<div className="flex items-center space-x-2 text-gray-300"> <div className="flex items-center space-x-2 text-stone-300">
<Tag className="w-4 h-4" /> <Tag className="w-4 h-4" />
<span className="capitalize">{formData.category}</span> <span className="capitalize">{formData.category}</span>
</div> </div>
{formData.featured && ( {formData.featured && (
<div className="flex items-center space-x-2 text-blue-400"> <div className="flex items-center space-x-2 text-stone-400">
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
Featured Featured
</span> </span>
@@ -853,7 +1057,7 @@ function EditorPageContent() {
{formData.tags.map((tag, index) => ( {formData.tags.map((tag, index) => (
<span <span
key={index} key={index}
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700" className="px-3 py-1 bg-gray-800/50 text-stone-300 text-sm rounded-full border border-gray-700"
> >
{tag} {tag}
</span> </span>
@@ -870,7 +1074,7 @@ function EditorPageContent() {
href={formData.github} href={formData.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center space-x-2 px-4 py-2 bg-gray-800/50 text-gray-300 rounded-lg" className="flex items-center space-x-2 px-4 py-2 bg-gray-800/50 text-stone-300 rounded-lg"
> >
<Github className="w-4 h-4" /> <Github className="w-4 h-4" />
<span>GitHub</span> <span>GitHub</span>
@@ -881,7 +1085,7 @@ function EditorPageContent() {
href={formData.live} href={formData.live}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center space-x-2 px-4 py-2 bg-blue-600/80 text-white rounded-lg" className="flex items-center space-x-2 px-4 py-2 bg-stone-600/80 text-white rounded-lg"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
<span>Live Demo</span> <span>Live Demo</span>
@@ -898,7 +1102,7 @@ function EditorPageContent() {
Content Content
</h3> </h3>
<div className="prose prose-invert max-w-none"> <div className="prose prose-invert max-w-none">
<div className="markdown text-gray-300 leading-relaxed"> <div className="markdown text-stone-300 leading-relaxed">
<ReactMarkdown components={markdownComponents}> <ReactMarkdown components={markdownComponents}>
{formData.content} {formData.content}
</ReactMarkdown> </ReactMarkdown>
@@ -921,7 +1125,7 @@ function EditorPageContent() {
{formData.published ? "Published" : "Draft"} {formData.published ? "Published" : "Draft"}
</span> </span>
{formData.featured && ( {formData.featured && (
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm font-medium"> <span className="px-3 py-1 bg-stone-500/20 text-stone-400 rounded-full text-sm font-medium">
Featured Featured
</span> </span>
)} )}

View File

@@ -80,7 +80,7 @@ html {
0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 20px 25px -5px rgba(0, 0, 0, 0.08),
0 10px 10px -5px rgba(0, 0, 0, 0.02), 0 10px 10px -5px rgba(0, 0, 0, 0.02),
inset 0 0 20px rgba(255, 255, 255, 0.8); inset 0 0 20px rgba(255, 255, 255, 0.8);
transform: translateY(-4px) scale(1.005); transform: translateY(-4px);
border-color: #ffffff; border-color: #ffffff;
} }
@@ -103,9 +103,6 @@ div {
color: #44403c; color: #44403c;
} }
/* Utility for the liquid melt effect container */
/* Liquid container removed - no filters applied */
/* Hide scrollbar but keep functionality */ /* Hide scrollbar but keep functionality */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -121,6 +118,14 @@ div {
background: #a8a29e; background: #a8a29e;
} }
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Animations */ /* Animations */
@keyframes float { @keyframes float {
0%, 0%,
@@ -137,18 +142,6 @@ div {
will-change: transform; will-change: transform;
} }
@keyframes liquid-pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
/* Liquid Blobs Background */ /* Liquid Blobs Background */
.liquid-bg-blob { .liquid-bg-blob {
position: absolute; position: absolute;
@@ -180,3 +173,43 @@ div {
.markdown pre { .markdown pre {
@apply bg-stone-900 text-stone-50 p-4 rounded-xl overflow-x-auto mb-6; @apply bg-stone-900 text-stone-50 p-4 rounded-xl overflow-x-auto mb-6;
} }
/* Admin Dashboard Styles - Organic Modern */
.animated-bg {
background: #fdfcf8;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.admin-glass {
background: rgba(253, 252, 248, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid #e7e5e4;
color: #292524;
}
.admin-glass-light {
background: #ffffff;
border: 1px solid #e7e5e4;
color: #292524;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.admin-glass-light:hover {
background: #fdfcf8;
border-color: #d6d3d1;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
.admin-glass-card {
background: #ffffff;
border: 1px solid #e7e5e4;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
color: #292524;
}

View File

@@ -2,11 +2,7 @@ import "./globals.css";
import { Metadata } from "next"; import { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import React from "react"; import React from "react";
import { ToastProvider } from "@/components/Toast"; import ClientProviders from "./components/ClientProviders";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import { ClientOnly } from "./components/ClientOnly";
import BackgroundBlobsClient from "./components/BackgroundBlobsClient";
import ChatWidget from "./components/ChatWidget";
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
@@ -29,16 +25,8 @@ export default function RootLayout({
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<title>Dennis Konkol&#39;s Portfolio</title> <title>Dennis Konkol&#39;s Portfolio</title>
</head> </head>
<body className={inter.variable}> <body className={inter.variable} suppressHydrationWarning>
<AnalyticsProvider> <ClientProviders>{children}</ClientProviders>
<ToastProvider>
<ClientOnly>
<BackgroundBlobsClient />
</ClientOnly>
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</AnalyticsProvider>
</body> </body>
</html> </html>
); );

View File

@@ -57,25 +57,42 @@ const AdminPage = () => {
// Check if user is locked out // Check if user is locked out
const checkLockout = useCallback(() => { const checkLockout = useCallback(() => {
const lockoutData = localStorage.getItem('admin_lockout'); if (typeof window === 'undefined') return false;
if (lockoutData) {
try { try {
const { timestamp, attempts } = JSON.parse(lockoutData); const lockoutData = localStorage.getItem('admin_lockout');
const now = Date.now(); if (lockoutData) {
try {
const { timestamp, attempts } = JSON.parse(lockoutData);
const now = Date.now();
if (now - timestamp < LOCKOUT_DURATION) { if (now - timestamp < LOCKOUT_DURATION) {
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isLocked: true, isLocked: true,
attempts, attempts,
isLoading: false isLoading: false
})); }));
return true; return true;
} else { } else {
localStorage.removeItem('admin_lockout'); try {
localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
}
} catch {
try {
localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
} }
} catch { }
localStorage.removeItem('admin_lockout'); } catch (error) {
// localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to check lockout status:', error);
} }
} }
return false; return false;
@@ -197,7 +214,11 @@ const AdminPage = () => {
attempts: 0, attempts: 0,
isLoading: false isLoading: false
})); }));
localStorage.removeItem('admin_lockout'); try {
localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
} else { } else {
const newAttempts = authState.attempts + 1; const newAttempts = authState.attempts + 1;
setAuthState(prev => ({ setAuthState(prev => ({
@@ -208,10 +229,17 @@ const AdminPage = () => {
})); }));
if (newAttempts >= 5) { if (newAttempts >= 5) {
localStorage.setItem('admin_lockout', JSON.stringify({ try {
timestamp: Date.now(), localStorage.setItem('admin_lockout', JSON.stringify({
attempts: newAttempts timestamp: Date.now(),
})); attempts: newAttempts
}));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to save lockout data:', error);
}
}
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isLocked: true, isLocked: true,
@@ -231,10 +259,10 @@ const AdminPage = () => {
// Loading state // Loading state
if (authState.isLoading) { if (authState.isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center bg-[#fdfcf8]">
<div className="text-center"> <div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-500" /> <Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-stone-600" />
<p className="text-white">Loading...</p> <p className="text-stone-500">Loading...</p>
</div> </div>
</div> </div>
); );
@@ -243,17 +271,23 @@ const AdminPage = () => {
// Lockout state // Lockout state
if (authState.isLocked) { if (authState.isLocked) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center bg-[#fdfcf8]">
<div className="text-center"> <div className="text-center">
<Lock className="w-16 h-16 mx-auto mb-4 text-red-500" /> <div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-6">
<h2 className="text-2xl font-bold text-white mb-2">Account Locked</h2> <Lock className="w-8 h-8 text-red-500" />
<p className="text-white/60">Too many failed attempts. Please try again in 15 minutes.</p> </div>
<h2 className="text-2xl font-bold text-stone-900 mb-2">Account Locked</h2>
<p className="text-stone-500">Too many failed attempts. Please try again in 15 minutes.</p>
<button <button
onClick={() => { onClick={() => {
localStorage.removeItem('admin_lockout'); try {
localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
window.location.reload(); window.location.reload();
}} }}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" className="mt-4 px-6 py-2 bg-stone-900 text-stone-50 rounded-xl hover:bg-stone-800 transition-colors"
> >
Try Again Try Again
</button> </button>
@@ -265,22 +299,23 @@ const AdminPage = () => {
// Login form // Login form
if (authState.showLogin || !authState.isAuthenticated) { if (authState.showLogin || !authState.isAuthenticated) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#fdfcf8] z-0">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md p-8" className="w-full max-w-md p-6"
> >
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20"> <div className="bg-white/80 backdrop-blur-xl rounded-3xl p-8 border border-stone-200 shadow-2xl relative z-10">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg"> <div className="w-16 h-16 bg-[#f3f1e7] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-sm border border-stone-100">
<Lock className="w-8 h-8 text-white" /> <Lock className="w-6 h-6 text-stone-600" />
</div> </div>
<h1 className="text-2xl font-bold text-white mb-2">Admin Access</h1> <h1 className="text-2xl font-bold text-stone-900 mb-2 tracking-tight">Admin Access</h1>
<p className="text-white/60">Enter your password to continue</p> <p className="text-stone-500">Enter your password to continue</p>
</div> </div>
<form onSubmit={handleLogin} className="space-y-6"> <form onSubmit={handleLogin} className="space-y-5">
<div> <div>
<div className="relative"> <div className="relative">
<input <input
@@ -288,37 +323,41 @@ const AdminPage = () => {
value={authState.password} value={authState.password}
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))} onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
placeholder="Enter password" placeholder="Enter password"
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-3.5 bg-white border border-stone-200 rounded-xl text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all shadow-sm"
disabled={authState.isLoading} disabled={authState.isLoading}
/> />
<button <button
type="button" type="button"
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))} onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1"
> >
{authState.showPassword ? '👁️' : '👁️‍🗨️'} {authState.showPassword ? '👁️' : '👁️‍🗨️'}
</button> </button>
</div> </div>
{authState.error && ( {authState.error && (
<p className="mt-2 text-red-400 text-sm">{authState.error}</p> <motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-red-500 text-sm font-medium flex items-center"
>
<span className="w-1.5 h-1.5 bg-red-500 rounded-full mr-2" />
{authState.error}
</motion.p>
)} )}
</div> </div>
<button <button
type="submit" type="submit"
disabled={authState.isLoading || !authState.password} disabled={authState.isLoading || !authState.password}
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white py-4 px-6 rounded-xl font-semibold text-lg hover:from-blue-600 hover:to-purple-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-transparent disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-[1.02] active:scale-[0.98] shadow-lg" className="w-full bg-stone-900 text-stone-50 py-3.5 px-6 rounded-xl font-semibold text-lg hover:bg-stone-800 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:ring-offset-2 focus:ring-offset-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg flex items-center justify-center"
> >
{authState.isLoading ? ( {authState.isLoading ? (
<div className="flex items-center justify-center space-x-3"> <div className="flex items-center justify-center space-x-2">
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
<span>Authenticating...</span> <span className="text-stone-50">Authenticating...</span>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center space-x-2"> <span className="text-stone-50">Sign In</span>
<Lock size={18} />
<span>Secure Login</span>
</div>
)} )}
</button> </button>
</form> </form>

View File

@@ -1,5 +1,80 @@
import KernelPanic404 from './components/KernelPanic404'; "use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// Dynamically import KernelPanic404Wrapper to avoid SSR issues
const KernelPanic404 = dynamic(() => import("./components/KernelPanic404Wrapper"), {
ssr: false,
loading: () => (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#020202",
color: "#33ff00",
fontFamily: "monospace"
}}>
<div>Loading terminal...</div>
</div>
),
});
export default function NotFound() { export default function NotFound() {
return <KernelPanic404 />; const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
margin: 0,
padding: 0,
overflow: "hidden",
backgroundColor: "#020202",
zIndex: 9998
}}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
color: "#33ff00",
fontFamily: "monospace"
}}>
Loading terminal...
</div>
</div>
);
}
return (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
margin: 0,
padding: 0,
overflow: "hidden",
backgroundColor: "#020202",
zIndex: 9998
}}>
<KernelPanic404 />
</div>
);
} }

View File

@@ -7,9 +7,16 @@ import Projects from "./components/Projects";
import Contact from "./components/Contact"; import Contact from "./components/Contact";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import Script from "next/script"; import Script from "next/script";
import ActivityFeed from "./components/ActivityFeed"; import dynamic from "next/dynamic";
import ErrorBoundary from "@/components/ErrorBoundary";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
// Wrap ActivityFeed in error boundary to prevent crashes
const ActivityFeed = dynamic(() => import("./components/ActivityFeed").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
export default function Home() { export default function Home() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -35,7 +42,9 @@ export default function Home() {
}), }),
}} }}
/> />
<ActivityFeed /> <ErrorBoundary>
<ActivityFeed />
</ErrorBoundary>
<Header /> <Header />
{/* Spacer to prevent navbar overlap */} {/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div> <div className="h-24 md:h-32" aria-hidden="true"></div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ExternalLink, Calendar, Tag, ArrowLeft, Github as GithubIcon } from 'lucide-react'; import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
@@ -18,6 +18,7 @@ interface Project {
date: string; date: string;
github?: string; github?: string;
live?: string; live?: string;
imageUrl?: string;
} }
const ProjectDetail = () => { const ProjectDetail = () => {
@@ -33,7 +34,28 @@ const ProjectDetail = () => {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.projects && data.projects.length > 0) { if (data.projects && data.projects.length > 0) {
setProject(data.projects[0]); 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) { } catch (error) {
@@ -48,142 +70,182 @@ const ProjectDetail = () => {
if (!project) { if (!project) {
return ( return (
<div className="min-h-screen animated-bg flex items-center justify-center"> <div className="min-h-screen bg-[#fdfcf8] flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-stone-800 mx-auto mb-4"></div>
<p className="text-gray-400">Loading project...</p> <p className="text-stone-500 font-medium">Loading project...</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen animated-bg"> <div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
<div className="max-w-4xl mx-auto px-4 pt-32 pb-20"> <div className="max-w-4xl mx-auto px-4">
{/* Header */} {/* Navigation */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }} transition={{ duration: 0.6 }}
className="mb-12" className="mb-8"
> >
<Link <Link
href="/projects" href="/projects"
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6" className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-900 transition-colors group"
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span>Back to Projects</span> <span className="font-medium">Back to Projects</span>
</Link> </Link>
<div className="flex items-center justify-between mb-6">
<h1 className="text-4xl md:text-5xl font-bold gradient-text">
{project.title}
</h1>
{project.featured && (
<span className="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-sm font-semibold rounded-full">
Featured
</span>
)}
</div>
<p className="text-xl text-gray-400 mb-6">
{project.description}
</p>
{/* Project Meta */}
<div className="flex flex-wrap items-center gap-6 text-gray-400 mb-8">
<div className="flex items-center space-x-2">
<Calendar size={20} />
<span>{project.date}</span>
</div>
<div className="flex items-center space-x-2">
<Tag size={20} />
<span>{project.category}</span>
</div>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-3 mb-8">
{project.tags.map((tag) => (
<span
key={tag}
className="px-4 py-2 bg-gray-800/50 text-gray-300 rounded-full border border-gray-700"
>
{tag}
</span>
))}
</div>
{/* Action Buttons */}
{((project.github && project.github.trim() && project.github !== "#") || (project.live && project.live.trim() && project.live !== "#")) && (
<div className="flex flex-wrap gap-4">
{project.github && project.github.trim() && project.github !== "#" && (
<motion.a
href={project.github}
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="inline-flex items-center space-x-2 px-6 py-3 bg-gray-800/50 hover:bg-gray-700/50 text-white rounded-lg transition-colors border border-gray-700"
>
<GithubIcon size={20} />
<span>View Code</span>
</motion.a>
)}
{project.live && project.live.trim() && project.live !== "#" && (
<motion.a
href={project.live}
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="inline-flex items-center space-x-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<ExternalLink size={20} />
<span>Live Demo</span>
</motion.a>
)}
</div>
)}
</motion.div> </motion.div>
{/* Project Content */} {/* Header & Meta */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.1 }}
className="glass-card p-8 rounded-2xl" className="mb-12"
> >
<div className="markdown prose prose-invert max-w-none text-white"> <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
<ReactMarkdown <h1 className="text-4xl md:text-6xl font-black font-sans text-stone-900 tracking-tight leading-tight">
components={{ {project.title}
h1: ({children}) => <h1 className="text-3xl font-bold text-white mb-4">{children}</h1>, </h1>
h2: ({children}) => <h2 className="text-2xl font-semibold text-white mb-3">{children}</h2>, <div className="flex gap-2 shrink-0 pt-2">
h3: ({children}) => <h3 className="text-xl font-semibold text-white mb-2">{children}</h3>, {project.featured && (
p: ({children}) => <p className="text-gray-300 mb-3 leading-relaxed">{children}</p>, <span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
ul: ({children}) => <ul className="list-disc list-inside text-gray-300 mb-3 space-y-1">{children}</ul>, Featured
ol: ({children}) => <ol className="list-decimal list-inside text-gray-300 mb-3 space-y-1">{children}</ol>, </span>
li: ({children}) => <li className="text-gray-300">{children}</li>, )}
a: ({href, children}) => ( <span className="px-4 py-1.5 bg-white border border-stone-200 text-stone-600 text-xs font-medium rounded-full shadow-sm">
<a href={href} className="text-blue-400 hover:text-blue-300 underline transition-colors" target="_blank" rel="noopener noreferrer"> {project.category}
{children} </span>
</a> </div>
), </div>
code: ({children}) => <code className="bg-gray-800 text-blue-400 px-2 py-1 rounded text-sm">{children}</code>,
pre: ({children}) => <pre className="bg-gray-800 p-4 rounded-lg overflow-x-auto mb-3">{children}</pre>, <p className="text-xl md:text-2xl text-stone-600 font-light leading-relaxed max-w-3xl mb-8">
blockquote: ({children}) => <blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-300 mb-3">{children}</blockquote>, {project.description}
strong: ({children}) => <strong className="font-semibold text-white">{children}</strong>, </p>
em: ({children}) => <em className="italic text-gray-300">{children}</em>
}} <div className="flex flex-wrap items-center gap-6 text-stone-500 text-sm border-y border-stone-200 py-6">
> <div className="flex items-center space-x-2">
{project.content} <Calendar size={18} />
</ReactMarkdown> <span className="font-mono">{new Date(project.date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span>
</div>
<div className="h-4 w-px bg-stone-300 hidden sm:block"></div>
<div className="flex flex-wrap gap-2">
{project.tags.map(tag => (
<span key={tag} className="text-stone-700 font-medium">#{tag}</span>
))}
</div>
</div> </div>
</motion.div> </motion.div>
{/* Featured Image / Fallback */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
>
{project.imageUrl ? (
<img
src={project.imageUrl}
alt={project.title}
className="w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-stone-200 to-stone-300 flex items-center justify-center">
<span className="text-9xl font-serif font-bold text-stone-500/20 select-none">
{project.title.charAt(0)}
</span>
</div>
)}
</motion.div>
{/* Content & Sidebar Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Main Content */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="lg:col-span-2"
>
<div className="markdown prose prose-stone max-w-none prose-lg prose-headings:font-bold prose-headings:tracking-tight prose-a:text-stone-900 prose-a:decoration-stone-300 hover:prose-a:decoration-stone-900 prose-img:rounded-xl prose-img:shadow-lg">
<ReactMarkdown
components={{
// Custom components to ensure styling matches
h1: ({children}) => <h1 className="text-3xl font-bold text-stone-900 mt-8 mb-4">{children}</h1>,
h2: ({children}) => <h2 className="text-2xl font-bold text-stone-900 mt-8 mb-4">{children}</h2>,
p: ({children}) => <p className="text-stone-700 leading-relaxed mb-6">{children}</p>,
li: ({children}) => <li className="text-stone-700">{children}</li>,
code: ({children}) => <code className="bg-stone-100 text-stone-800 px-1.5 py-0.5 rounded text-sm font-mono font-medium">{children}</code>,
pre: ({children}) => <pre className="bg-stone-900 text-stone-50 p-6 rounded-xl overflow-x-auto my-6 shadow-lg">{children}</pre>,
}}
>
{project.content}
</ReactMarkdown>
</div>
</motion.div>
{/* Sidebar / Actions */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
className="lg:col-span-1 space-y-8"
>
<div className="bg-white/50 backdrop-blur-xl border border-white/60 p-6 rounded-2xl shadow-sm sticky top-32">
<h3 className="font-bold text-stone-900 mb-4 flex items-center gap-2">
<Share2 size={18} />
Project Links
</h3>
<div className="space-y-3">
{project.live && project.live.trim() && project.live !== "#" ? (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-4 py-3 bg-stone-900 text-stone-50 rounded-xl font-medium hover:bg-stone-800 hover:scale-[1.02] transition-all shadow-md group"
>
<span>Live Demo</span>
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
</a>
) : (
<div className="px-4 py-3 bg-stone-100 text-stone-400 rounded-xl font-medium text-sm text-center border border-stone-200 cursor-not-allowed">
Live demo not available
</div>
)}
{project.github && project.github.trim() && project.github !== "#" ? (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-4 py-3 bg-white border border-stone-200 text-stone-700 rounded-xl font-medium hover:bg-stone-50 hover:text-stone-900 hover:border-stone-300 transition-all shadow-sm group"
>
<span>View Source</span>
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
</a>
) : null}
</div>
<div className="mt-8 pt-6 border-t border-stone-100">
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">Tech Stack</h4>
<div className="flex flex-wrap gap-2">
{project.tags.map(tag => (
<span key={tag} className="px-2.5 py-1 bg-stone-100 text-stone-600 text-xs font-medium rounded-md border border-stone-200">
{tag}
</span>
))}
</div>
</div>
</div>
</motion.div>
</div>
</div> </div>
</div> </div>
); );
}; };
export default ProjectDetail; export default ProjectDetail;

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ExternalLink, Github, Calendar, ArrowLeft } from 'lucide-react'; import { ExternalLink, Github, Calendar, ArrowLeft, Search } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
interface Project { interface Project {
@@ -17,10 +16,16 @@ interface Project {
date: string; date: string;
github?: string; github?: string;
live?: string; live?: string;
imageUrl?: string;
} }
const ProjectsPage = () => { const ProjectsPage = () => {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
const [categories, setCategories] = useState<string[]>(["All"]);
const [selectedCategory, setSelectedCategory] = useState("All");
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
// Load projects from API // Load projects from API
useEffect(() => { useEffect(() => {
@@ -29,7 +34,12 @@ const ProjectsPage = () => {
const response = await fetch('/api/projects?published=true'); const response = await fetch('/api/projects?published=true');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setProjects(data.projects || []); const loadedProjects = data.projects || [];
setProjects(loadedProjects);
// Extract unique categories
const uniqueCategories = ["All", ...Array.from(new Set(loadedProjects.map((p: Project) => p.category))) as string[]];
setCategories(uniqueCategories);
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
@@ -39,31 +49,36 @@ const ProjectsPage = () => {
}; };
loadProjects(); loadProjects();
}, []);
const categories = ["All", "Web Development", "Full-Stack", "Web Application", "Mobile App"];
const [selectedCategory, setSelectedCategory] = useState("All");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
if (!mounted) { // Filter projects
return null; useEffect(() => {
} let result = projects;
const filteredProjects = selectedCategory === "All" if (selectedCategory !== "All") {
? projects result = result.filter(project => project.category === selectedCategory);
: projects.filter(project => project.category === selectedCategory); }
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(project =>
project.title.toLowerCase().includes(query) ||
project.description.toLowerCase().includes(query) ||
project.tags.some(tag => tag.toLowerCase().includes(query))
);
}
setFilteredProjects(result);
}, [projects, selectedCategory, searchQuery]);
if (!mounted) { if (!mounted) {
return null; return null;
} }
return ( return (
<div className="min-h-screen animated-bg"> <div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
<div className="max-w-7xl mx-auto px-4 pt-32 pb-20"> <div className="max-w-7xl mx-auto px-4">
{/* Header */} {/* Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@@ -73,43 +88,56 @@ const ProjectsPage = () => {
> >
<Link <Link
href="/" href="/"
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6" className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span>Back to Home</span> <span>Back to Home</span>
</Link> </Link>
<h1 className="text-5xl md:text-6xl font-bold mb-6 gradient-text"> <h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
My Projects My Projects
</h1> </h1>
<p className="text-xl text-gray-400 max-w-3xl"> <p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">
Explore my portfolio of projects, from web applications to mobile apps. Explore my portfolio of projects, from web applications to mobile apps.
Each project showcases different skills and technologies. Each project showcases different skills and technologies.
</p> </p>
</motion.div> </motion.div>
{/* Category Filter */} {/* Filters & Search */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
className="mb-12" className="mb-12 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center"
> >
<div className="flex flex-wrap gap-3"> {/* Categories */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => ( {categories.map((category) => (
<button <button
key={category} key={category}
onClick={() => setSelectedCategory(category)} onClick={() => setSelectedCategory(category)}
className={`px-6 py-3 rounded-lg font-medium transition-all duration-200 ${ className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
selectedCategory === category selectedCategory === category
? 'bg-gray-800 text-cream shadow-lg' ? 'bg-stone-800 text-stone-50 border-stone-800 shadow-md'
: 'bg-gray-800/50 text-gray-300 hover:bg-gray-700/50 hover:text-white' : 'bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300'
}`} }`}
> >
{category} {category}
</button> </button>
))} ))}
</div> </div>
{/* Search */}
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<input
type="text"
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
/>
</div>
</motion.div> </motion.div>
{/* Projects Grid */} {/* Projects Grid */}
@@ -120,98 +148,158 @@ const ProjectsPage = () => {
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }} transition={{ duration: 0.6, delay: index * 0.1 }}
whileHover={{ y: -10 }} whileHover={{ y: -8 }}
className="group relative overflow-hidden rounded-2xl glass-card card-hover" className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
> >
<div className="relative h-48 overflow-hidden"> {/* Image / Fallback / Cover Area */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20" /> <div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
<div className="absolute inset-0 bg-gray-800/50 flex flex-col items-center justify-center p-4"> {project.imageUrl ? (
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center mb-2"> <>
<span className="text-2xl font-bold text-white"> <img
{project.title.split(' ').map(word => word[0]).join('').toUpperCase()} src={project.imageUrl}
</span> alt={project.title}
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : (
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
</span>
</div>
</div> </div>
<span className="text-sm font-medium text-gray-400 text-center leading-tight"> )}
{project.title}
</span>
</div>
{/* Texture/Grain Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{project.featured && ( {project.featured && (
<div className="absolute top-4 right-4 px-3 py-1 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-xs font-semibold rounded-full"> <div className="absolute top-3 left-3 z-20">
Featured <div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
Featured
</div>
</div> </div>
)} )}
{((project.github && project.github.trim() && project.github !== "#") || (project.live && project.live.trim() && project.live !== "#")) && ( {/* Overlay Links */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center space-x-4"> <div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && project.github.trim() && project.github !== "#" && ( {project.github && (
<motion.a <a
href={project.github} href={project.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
whileHover={{ scale: 1.1 }} className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
whileTap={{ scale: 0.95 }} aria-label="GitHub"
className="p-3 bg-gray-800/80 rounded-lg text-white hover:bg-gray-700/80 transition-colors" onClick={(e) => e.stopPropagation()}
> >
<Github size={20} /> <Github size={20} />
</motion.a> </a>
)} )}
{project.live && project.live.trim() && project.live !== "#" && ( {project.live && !project.title.toLowerCase().includes('kernel panic') && (
<motion.a <a
href={project.live} href={project.live}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
whileHover={{ scale: 1.1 }} className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
whileTap={{ scale: 0.95 }} aria-label="Live Demo"
className="p-3 bg-blue-600/80 rounded-lg text-white hover:bg-blue-500/80 transition-colors" onClick={(e) => e.stopPropagation()}
> >
<ExternalLink size={20} /> <ExternalLink size={20} />
</motion.a> </a>
)} )}
</div> </div>
)}
</div> </div>
<div className="p-6"> <div className="p-6 flex flex-col flex-1">
{/* Stretched Link covering the whole card (including image area) */}
<Link
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors"> <h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title} {project.title}
</h3> </h3>
<div className="flex items-center space-x-2 text-gray-400"> <div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
<Calendar size={16} /> <Calendar size={12} />
<span className="text-sm">{project.date}</span> <span>{new Date(project.date).getFullYear()}</span>
</div> </div>
</div> </div>
<p className="text-gray-300 mb-4 leading-relaxed"> <p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
{project.description} {project.description}
</p> </p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-6">
{project.tags.map((tag) => ( {project.tags.slice(0, 4).map((tag) => (
<span <span
key={tag} key={tag}
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700" className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
> >
{tag} {tag}
</span> </span>
))} ))}
{project.tags.length > 4 && (
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
)}
</div> </div>
<Link <div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`} <div className="flex gap-3">
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors font-medium" {project.github && (
> <a
<span>View Project</span> href={project.github}
<ExternalLink size={16} /> target="_blank"
</Link> rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div>
</div>
</div> </div>
</motion.div> </motion.div>
))} ))}
</div> </div>
{filteredProjects.length === 0 && (
<div className="text-center py-20">
<p className="text-stone-500 text-lg">No projects found matching your criteria.</p>
<button
onClick={() => {setSelectedCategory("All"); setSearchQuery("");}}
className="mt-4 text-stone-800 font-medium hover:underline"
>
Clear filters
</button>
</div>
)}
</div> </div>
</div> </div>
); );
}; };
export default ProjectsPage; export default ProjectsPage;

View File

@@ -4,9 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
BarChart3, BarChart3,
TrendingUp,
Eye, Eye,
Heart,
Zap, Zap,
Globe, Globe,
Activity, Activity,
@@ -18,6 +16,7 @@ import {
Trash2, Trash2,
AlertTriangle AlertTriangle
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/components/Toast';
interface AnalyticsData { interface AnalyticsData {
overview: { overview: {
@@ -25,8 +24,6 @@ interface AnalyticsData {
publishedProjects: number; publishedProjects: number;
featuredProjects: number; featuredProjects: number;
totalViews: number; totalViews: number;
totalLikes: number;
totalShares: number;
avgLighthouse: number; avgLighthouse: number;
}; };
projects: Array<{ projects: Array<{
@@ -35,8 +32,6 @@ interface AnalyticsData {
category: string; category: string;
difficulty: string; difficulty: string;
views: number; views: number;
likes: number;
shares: number;
lighthouse: number; lighthouse: number;
published: boolean; published: boolean;
featured: boolean; featured: boolean;
@@ -48,8 +43,6 @@ interface AnalyticsData {
performance: { performance: {
avgLighthouse: number; avgLighthouse: number;
totalViews: number; totalViews: number;
totalLikes: number;
totalShares: number;
}; };
metrics: { metrics: {
bounceRate: number; bounceRate: number;
@@ -71,6 +64,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
const [showResetModal, setShowResetModal] = useState(false); const [showResetModal, setShowResetModal] = useState(false);
const [resetType, setResetType] = useState<'analytics' | 'pageviews' | 'interactions' | 'performance' | 'all'>('analytics'); const [resetType, setResetType] = useState<'analytics' | 'pageviews' | 'interactions' | 'performance' | 'all'>('analytics');
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const { showSuccess, showError } = useToast();
const fetchAnalyticsData = useCallback(async () => { const fetchAnalyticsData = useCallback(async () => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
@@ -79,11 +73,13 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
setLoading(true); setLoading(true);
setError(null); setError(null);
// Add cache-busting parameter to ensure fresh data after reset
const cacheBust = `?nocache=true&t=${Date.now()}`;
const [analyticsRes, performanceRes] = await Promise.all([ const [analyticsRes, performanceRes] = await Promise.all([
fetch('/api/analytics/dashboard', { fetch(`/api/analytics/dashboard${cacheBust}`, {
headers: { 'x-admin-request': 'true' } headers: { 'x-admin-request': 'true' }
}), }),
fetch('/api/analytics/performance', { fetch(`/api/analytics/performance${cacheBust}`, {
headers: { 'x-admin-request': 'true' } headers: { 'x-admin-request': 'true' }
}) })
]); ]);
@@ -103,23 +99,19 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
publishedProjects: 0, publishedProjects: 0,
featuredProjects: 0, featuredProjects: 0,
totalViews: 0, totalViews: 0,
totalLikes: 0,
totalShares: 0,
avgLighthouse: 90 avgLighthouse: 90
}, },
projects: analytics.projects || [], projects: analytics.projects || [],
categories: analytics.categories || {}, categories: analytics.categories || {},
difficulties: analytics.difficulties || {}, difficulties: analytics.difficulties || {},
performance: performance.performance || { performance: {
avgLighthouse: 90, avgLighthouse: performance.avgLighthouse || analytics.overview?.avgLighthouse || 0,
totalViews: 0, totalViews: performance.totalViews || analytics.overview?.totalViews || 0,
totalLikes: 0,
totalShares: 0
}, },
metrics: performance.metrics || { metrics: performance.metrics || analytics.metrics || {
bounceRate: 35, bounceRate: 0,
avgSessionDuration: 180, avgSessionDuration: 0,
pagesPerSession: 2.5, pagesPerSession: 0,
newUsers: 0 newUsers: 0
} }
}); });
@@ -134,6 +126,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
if (!isAuthenticated || resetting) return; if (!isAuthenticated || resetting) return;
setResetting(true); setResetting(true);
setError(null);
try { try {
const response = await fetch('/api/analytics/reset', { const response = await fetch('/api/analytics/reset', {
method: 'POST', method: 'POST',
@@ -144,15 +137,25 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
body: JSON.stringify({ type: resetType }) body: JSON.stringify({ type: resetType })
}); });
const result = await response.json();
if (response.ok) { if (response.ok) {
await fetchAnalyticsData(); // Refresh data showSuccess(
'Analytics Reset',
`Successfully reset ${resetType === 'all' ? 'all analytics data' : resetType} data.`
);
setShowResetModal(false); setShowResetModal(false);
// Clear cache and refresh data
await fetchAnalyticsData();
} else { } else {
const errorData = await response.json(); const errorMsg = result.error || 'Failed to reset analytics';
setError(errorData.error || 'Failed to reset analytics'); setError(errorMsg);
showError('Reset Failed', errorMsg);
} }
} catch (err) { } catch (err) {
setError('Failed to reset analytics'); const errorMsg = 'Failed to reset analytics. Please try again.';
setError(errorMsg);
showError('Reset Failed', errorMsg);
console.error('Reset error:', err); console.error('Reset error:', err);
} finally { } finally {
setResetting(false); setResetting(false);
@@ -165,63 +168,59 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
} }
}, [isAuthenticated, fetchAnalyticsData]); }, [isAuthenticated, fetchAnalyticsData]);
const StatCard = ({ title, value, icon: Icon, color, trend, trendValue, description }: { const StatCard = ({ title, value, icon: Icon, color, description, tooltip }: {
title: string; title: string;
value: number | string; value: number | string;
icon: React.ComponentType<{ className?: string; size?: number }>; icon: React.ComponentType<{ className?: string; size?: number }>;
color: string; color: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
description?: string; description?: string;
tooltip?: string;
}) => ( }) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="admin-glass-card p-6 rounded-xl hover:scale-105 transition-all duration-200" className="bg-white border border-stone-200 p-6 rounded-xl hover:shadow-md transition-all duration-200 group relative"
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3 mb-4"> <div className="flex items-center space-x-3 mb-4">
<div className={`p-3 rounded-xl ${color}`}> <div className={`p-3 rounded-xl ${color}`}>
<Icon className="w-6 h-6 text-white" size={24} /> <Icon className="w-6 h-6" size={24} />
</div> </div>
<div> <div>
<p className="text-white/60 text-sm font-medium">{title}</p> <p className="text-stone-500 text-sm font-medium">{title}</p>
{description && <p className="text-white/40 text-xs">{description}</p>} {description && <p className="text-stone-400 text-xs">{description}</p>}
</div> </div>
</div> </div>
<p className="text-3xl font-bold text-white mb-2">{value}</p> <p className="text-3xl font-bold text-stone-900 mb-2">{value}</p>
{trend && trendValue && (
<div className={`flex items-center space-x-1 text-sm ${
trend === 'up' ? 'text-green-400' :
trend === 'down' ? 'text-red-400' : 'text-yellow-400'
}`}>
<TrendingUp className={`w-4 h-4 ${trend === 'down' ? 'rotate-180' : ''}`} />
<span>{trendValue}</span>
</div>
)}
</div> </div>
</div> </div>
{tooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
{tooltip}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
)}
</motion.div> </motion.div>
); );
const getDifficultyColor = (difficulty: string) => { const getDifficultyColor = (difficulty: string) => {
switch (difficulty) { switch (difficulty) {
case 'Beginner': return 'bg-green-500/30 text-green-400 border-green-500/40'; case 'Beginner': return 'bg-stone-50 text-stone-700 border-stone-200';
case 'Intermediate': return 'bg-yellow-500/30 text-yellow-400 border-yellow-500/40'; case 'Intermediate': return 'bg-stone-100 text-stone-700 border-stone-300';
case 'Advanced': return 'bg-orange-500/30 text-orange-400 border-orange-500/40'; case 'Advanced': return 'bg-stone-200 text-stone-800 border-stone-400';
case 'Expert': return 'bg-red-500/30 text-red-400 border-red-500/40'; case 'Expert': return 'bg-stone-300 text-stone-900 border-stone-500';
default: return 'bg-gray-500/30 text-gray-400 border-gray-500/40'; default: return 'bg-stone-50 text-stone-600 border-stone-200';
} }
}; };
const getCategoryColor = (index: number) => { const getCategoryColor = (index: number) => {
const colors = [ const colors = [
'bg-blue-500/30 text-blue-400', 'bg-stone-100 text-stone-700',
'bg-purple-500/30 text-purple-400', 'bg-stone-200 text-stone-800',
'bg-green-500/30 text-green-400', 'bg-stone-300 text-stone-900',
'bg-pink-500/30 text-pink-400', 'bg-stone-100 text-stone-700',
'bg-indigo-500/30 text-indigo-400' 'bg-stone-200 text-stone-800'
]; ];
return colors[index % colors.length]; return colors[index % colors.length];
}; };
@@ -233,23 +232,23 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-white flex items-center"> <h1 className="text-3xl font-bold text-stone-900 flex items-center">
<BarChart3 className="w-8 h-8 mr-3 text-blue-400" /> <BarChart3 className="w-8 h-8 mr-3 text-stone-600" />
Analytics Dashboard Analytics Dashboard
</h1> </h1>
<p className="text-white/80 mt-2">Portfolio performance and user engagement metrics</p> <p className="text-stone-500 mt-2">Portfolio performance and analytics metrics</p>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{/* Time Range Selector */} {/* Time Range Selector */}
<div className="flex items-center space-x-1 admin-glass-light rounded-xl p-1"> <div className="flex items-center space-x-1 bg-white border border-stone-200 rounded-xl p-1">
{(['7d', '30d', '90d', '1y'] as const).map((range) => ( {(['7d', '30d', '90d', '1y'] as const).map((range) => (
<button <button
key={range} key={range}
onClick={() => setTimeRange(range)} onClick={() => setTimeRange(range)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${ className={`px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
timeRange === range timeRange === range
? 'bg-blue-500/40 text-blue-300 shadow-lg' ? 'bg-stone-100 text-stone-900 shadow-sm'
: 'text-white/70 hover:text-white hover:bg-white/10' : 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
}`} }`}
> >
{range === '7d' ? '7 Days' : range === '30d' ? '30 Days' : range === '90d' ? '90 Days' : '1 Year'} {range === '7d' ? '7 Days' : range === '30d' ? '30 Days' : range === '90d' ? '90 Days' : '1 Year'}
@@ -259,15 +258,15 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
<button <button
onClick={fetchAnalyticsData} onClick={fetchAnalyticsData}
disabled={loading} disabled={loading}
className="flex items-center space-x-2 px-4 py-2 admin-glass-light rounded-xl hover:scale-105 transition-all duration-200 disabled:opacity-50" className="flex items-center space-x-2 px-4 py-2 bg-white border border-stone-200 rounded-xl hover:bg-stone-50 transition-all duration-200 disabled:opacity-50 text-stone-600"
> >
<RefreshCw className={`w-4 h-4 text-blue-400 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 text-stone-600 ${loading ? 'animate-spin' : ''}`} />
<span className="text-white font-medium">Refresh</span> <span className="font-medium">Refresh</span>
</button> </button>
<button <button
onClick={() => setShowResetModal(true)} onClick={() => setShowResetModal(true)}
className="flex items-center space-x-2 px-4 py-2 bg-red-600/20 text-red-400 border border-red-500/30 rounded-xl hover:bg-red-600/30 hover:scale-105 transition-all" className="flex items-center space-x-2 px-4 py-2 bg-red-50 text-red-600 border border-red-100 rounded-xl hover:bg-red-100 transition-all"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
<span>Reset</span> <span>Reset</span>
@@ -276,17 +275,17 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
</div> </div>
{loading && ( {loading && (
<div className="admin-glass-card p-8 rounded-xl"> <div className="bg-white border border-stone-200 p-8 rounded-xl shadow-sm">
<div className="flex items-center justify-center space-x-3"> <div className="flex items-center justify-center space-x-3">
<RefreshCw className="w-6 h-6 text-blue-400 animate-spin" /> <RefreshCw className="w-6 h-6 text-stone-600 animate-spin" />
<span className="text-white/80 text-lg">Loading analytics data...</span> <span className="text-stone-500 text-lg">Loading analytics data...</span>
</div> </div>
</div> </div>
)} )}
{error && ( {error && (
<div className="admin-glass-card p-6 rounded-xl border border-red-500/40"> <div className="bg-white border border-red-200 p-6 rounded-xl">
<div className="flex items-center space-x-3 text-red-300"> <div className="flex items-center space-x-3 text-red-600">
<Activity className="w-5 h-5" /> <Activity className="w-5 h-5" />
<span>Error: {error}</span> <span>Error: {error}</span>
</div> </div>
@@ -297,8 +296,8 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
<> <>
{/* Overview Stats */} {/* Overview Stats */}
<div> <div>
<h2 className="text-xl font-bold text-white mb-6 flex items-center"> <h2 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Target className="w-5 h-5 mr-2 text-purple-400" /> <Target className="w-5 h-5 mr-2 text-stone-600" />
Overview Overview
</h2> </h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
@@ -306,46 +305,43 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
title="Total Views" title="Total Views"
value={data.overview.totalViews.toLocaleString()} value={data.overview.totalViews.toLocaleString()}
icon={Eye} icon={Eye}
color="bg-blue-500/30" color="bg-stone-100 text-stone-600"
trend="up"
trendValue="+12.5%"
description="All-time page views" description="All-time page views"
tooltip="✅ REAL DATA: Total page views tracked from the PageView database table. Each visit to a project page or the homepage is automatically recorded with IP, user agent, and timestamp."
/> />
<StatCard <StatCard
title="Projects" title="Projects"
value={data.overview.totalProjects} value={data.overview.totalProjects}
icon={Globe} icon={Globe}
color="bg-green-500/30" color="bg-stone-100 text-stone-600"
trend="up"
trendValue="+2"
description={`${data.overview.publishedProjects} published`} description={`${data.overview.publishedProjects} published`}
/> tooltip="✅ REAL DATA: Total number of projects in your portfolio. Shows published vs unpublished projects from your database."
<StatCard
title="Engagement"
value={data.overview.totalLikes}
icon={Heart}
color="bg-pink-500/30"
trend="up"
trendValue="+8.2%"
description="Total likes & shares"
/> />
<StatCard <StatCard
title="Performance" title="Performance"
value={data.overview.avgLighthouse} value={data.overview.avgLighthouse > 0 ? data.overview.avgLighthouse : 'N/A'}
icon={Zap} icon={Zap}
color="bg-orange-500/30" color="bg-stone-100 text-stone-600"
trend="up" description={data.overview.avgLighthouse > 0 ? "Avg Lighthouse score" : "No performance data yet"}
trendValue="+5%" tooltip={data.overview.avgLighthouse > 0
description="Avg Lighthouse score" ? "✅ REAL DATA: Average Lighthouse performance score (0-100) calculated from real Web Vitals metrics (LCP, FCP, CLS, FID, TTFB) collected from actual page visits. Only shown when real performance data exists."
: "No performance data collected yet. Scores will appear after visitors load your pages and Web Vitals are tracked."}
/> />
<StatCard <StatCard
title="Bounce Rate" title="Bounce Rate"
value={`${data.metrics.bounceRate}%`} value={`${data.metrics?.bounceRate || 0}%`}
icon={MousePointer} icon={MousePointer}
color="bg-purple-500/30" color="bg-stone-100 text-stone-600"
trend="down"
trendValue="-2.1%"
description="User retention" description="User retention"
tooltip="✅ REAL DATA: Percentage of sessions where users viewed only one page before leaving. Calculated from PageView records grouped by IP address. Lower is better."
/>
<StatCard
title="Avg Session"
value={data.metrics?.avgSessionDuration ? `${Math.round(data.metrics.avgSessionDuration / 60)}m` : '0m'}
icon={Activity}
color="bg-stone-100 text-stone-600"
description="Average session duration"
tooltip="✅ REAL DATA: Average time users spend on your site per session, calculated from the time difference between first and last pageview per IP address. Only calculated for sessions with multiple pageviews."
/> />
</div> </div>
</div> </div>
@@ -353,9 +349,9 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
{/* Project Performance */} {/* Project Performance */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Projects */} {/* Top Projects */}
<div className="admin-glass-card p-6 rounded-xl"> <div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-white mb-6 flex items-center"> <h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Award className="w-5 h-5 mr-2 text-yellow-400" /> <Award className="w-5 h-5 mr-2 text-stone-600" />
Top Performing Projects Top Performing Projects
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -368,20 +364,24 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.1 }}
className="flex items-center justify-between p-4 admin-glass-light rounded-xl" className="flex items-center justify-between p-4 bg-stone-50 rounded-xl border border-stone-100"
> >
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center text-white font-bold"> <div className="w-8 h-8 bg-stone-600 rounded-lg flex items-center justify-center text-white font-bold shadow-sm">
#{index + 1} #{index + 1}
</div> </div>
<div> <div>
<p className="text-white font-medium">{project.title}</p> <p className="text-stone-900 font-medium">{project.title}</p>
<p className="text-white/60 text-sm">{project.category}</p> <p className="text-stone-500 text-sm">{project.category}</p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right group/views relative">
<p className="text-white font-bold">{project.views.toLocaleString()}</p> <p className="text-stone-900 font-bold">{project.views.toLocaleString()}</p>
<p className="text-white/60 text-sm">views</p> <p className="text-stone-500 text-sm">views</p>
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover/views:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Page views tracked from PageView table for this project. Each visit is automatically recorded.
<div className="absolute top-full right-4 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
</div> </div>
</motion.div> </motion.div>
))} ))}
@@ -389,9 +389,9 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
</div> </div>
{/* Categories Distribution */} {/* Categories Distribution */}
<div className="admin-glass-card p-6 rounded-xl"> <div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-white mb-6 flex items-center"> <h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<BarChart3 className="w-5 h-5 mr-2 text-green-400" /> <BarChart3 className="w-5 h-5 mr-2 text-stone-600" />
Categories Categories
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -405,16 +405,16 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${getCategoryColor(index)}`}></div> <div className={`w-4 h-4 rounded-full ${getCategoryColor(index)}`}></div>
<span className="text-white font-medium">{category}</span> <span className="text-stone-700 font-medium">{category}</span>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-32 h-2 bg-white/10 rounded-full overflow-hidden"> <div className="w-32 h-2 bg-stone-100 rounded-full overflow-hidden">
<div <div
className={`h-full ${getCategoryColor(index)} transition-all duration-500`} className={`h-full ${getCategoryColor(index)} transition-all duration-500`}
style={{ width: `${(count / Math.max(...Object.values(data.categories))) * 100}%` }} style={{ width: `${(count / Math.max(...Object.values(data.categories))) * 100}%` }}
></div> ></div>
</div> </div>
<span className="text-white/80 font-medium w-8 text-right">{count}</span> <span className="text-stone-500 font-medium w-8 text-right">{count}</span>
</div> </div>
</motion.div> </motion.div>
))} ))}
@@ -422,12 +422,12 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
</div> </div>
</div> </div>
{/* Difficulty & Engagement */} {/* Difficulty & Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Difficulty Distribution */} {/* Difficulty Distribution */}
<div className="admin-glass-card p-6 rounded-xl"> <div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-white mb-6 flex items-center"> <h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Target className="w-5 h-5 mr-2 text-red-400" /> <Target className="w-5 h-5 mr-2 text-stone-600" />
Difficulty Levels Difficulty Levels
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -448,9 +448,9 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
</div> </div>
{/* Recent Activity */} {/* Recent Activity */}
<div className="admin-glass-card p-6 rounded-xl"> <div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-white mb-6 flex items-center"> <h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Activity className="w-5 h-5 mr-2 text-blue-400" /> <Activity className="w-5 h-5 mr-2 text-blue-600" />
Recent Activity Recent Activity
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -463,25 +463,25 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.1 }}
className="flex items-center space-x-4 p-3 admin-glass-light rounded-xl" className="flex items-center space-x-4 p-3 bg-stone-50 rounded-xl border border-stone-100"
> >
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-stone-500 rounded-full animate-pulse"></div>
<div className="flex-1"> <div className="flex-1">
<p className="text-white font-medium text-sm">{project.title}</p> <p className="text-stone-900 font-medium text-sm">{project.title}</p>
<p className="text-white/60 text-xs"> <p className="text-stone-500 text-xs">
Updated {new Date(project.updatedAt).toLocaleDateString()} Updated {new Date(project.updatedAt).toLocaleDateString()}
</p> </p>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{project.featured && ( {project.featured && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded-full text-xs"> <span className="px-2 py-1 bg-stone-100 text-stone-700 rounded-full text-xs font-medium">
Featured Featured
</span> </span>
)} )}
<span className={`px-2 py-1 rounded-full text-xs ${ <span className={`px-2 py-1 rounded-full text-xs font-medium ${
project.published project.published
? 'bg-green-500/20 text-green-400' ? 'bg-stone-100 text-stone-700'
: 'bg-yellow-500/20 text-yellow-400' : 'bg-stone-200 text-stone-700'
}`}> }`}>
{project.published ? 'Live' : 'Draft'} {project.published ? 'Live' : 'Draft'}
</span> </span>
@@ -496,43 +496,43 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
{/* Reset Modal */} {/* Reset Modal */}
{showResetModal && ( {showResetModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
className="admin-glass-card rounded-2xl p-6 w-full max-w-md" className="bg-white border border-stone-200 rounded-2xl p-6 w-full max-w-md shadow-xl"
> >
<div className="flex items-center space-x-3 mb-4"> <div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-400" /> <AlertTriangle className="w-5 h-5 text-red-600" />
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-white">Reset Analytics Data</h3> <h3 className="text-lg font-bold text-stone-900">Reset Analytics Data</h3>
<p className="text-white/60 text-sm">This action cannot be undone</p> <p className="text-stone-500 text-sm">This action cannot be undone</p>
</div> </div>
</div> </div>
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div> <div>
<label className="block text-white/80 text-sm mb-2">Reset Type</label> <label className="block text-stone-600 text-sm mb-2">Reset Type</label>
<select <select
value={resetType} value={resetType}
onChange={(e) => setResetType(e.target.value as 'all' | 'performance' | 'analytics')} onChange={(e) => setResetType(e.target.value as 'analytics' | 'pageviews' | 'interactions' | 'performance' | 'all')}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-red-500" className="w-full px-3 py-2 bg-stone-50 border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-red-500"
> >
<option value="analytics">Analytics Only (views, likes, shares)</option> <option value="analytics">Analytics Only (project view counts)</option>
<option value="pageviews">Page Views Only</option> <option value="pageviews">Page Views Only (all tracked visits)</option>
<option value="interactions">User Interactions Only</option> <option value="interactions">User Interactions Only</option>
<option value="performance">Performance Metrics Only</option> <option value="performance">Performance Metrics Only (Lighthouse scores)</option>
<option value="all">Everything (Complete Reset)</option> <option value="all">Everything (Complete Reset)</option>
</select> </select>
</div> </div>
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3"> <div className="bg-red-50 border border-red-100 rounded-lg p-3">
<div className="flex items-start space-x-2"> <div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" /> <AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-300"> <div className="text-sm text-red-700">
<p className="font-medium mb-1">Warning:</p> <p className="font-medium mb-1">Warning:</p>
<p>This will permanently delete the selected analytics data. This action cannot be reversed.</p> <p>This will permanently delete the selected analytics data. This action cannot be reversed.</p>
</div> </div>
@@ -544,14 +544,14 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
<button <button
onClick={() => setShowResetModal(false)} onClick={() => setShowResetModal(false)}
disabled={resetting} disabled={resetting}
className="flex-1 px-4 py-2 admin-glass-light text-white rounded-lg hover:scale-105 transition-all disabled:opacity-50" className="flex-1 px-4 py-2 bg-white border border-stone-200 text-stone-700 rounded-lg hover:bg-stone-50 transition-all disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={resetAnalytics} onClick={resetAnalytics}
disabled={resetting} disabled={resetting}
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg hover:scale-105 transition-all disabled:opacity-50" className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-all disabled:opacity-50"
> >
{resetting ? ( {resetting ? (
<> <>

View File

@@ -9,27 +9,126 @@ interface AnalyticsProviderProps {
} }
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => { export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
// Initialize Web Vitals tracking // Initialize Web Vitals tracking - wrapped to prevent crashes
// Hooks must be called unconditionally, but the hook itself handles errors
useWebVitals(); useWebVitals();
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
// Wrap entire effect in try-catch to prevent any errors from breaking the app
try {
// Track page view // Track page view
const trackPageView = () => { const trackPageView = async () => {
const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null;
// Track to Umami (if available)
trackEvent('page-view', { trackEvent('page-view', {
url: window.location.pathname, url: path,
referrer: document.referrer, referrer: document.referrer,
timestamp: Date.now(), timestamp: Date.now(),
}); });
// Track to our API
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);
}
}
}; };
// Track page load performance // Track page load performance - wrapped in try-catch
trackPageLoad(); try {
trackPageLoad();
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking page load:', error);
}
}
// Track initial page view // Track initial page view
trackPageView(); trackPageView();
// Track performance metrics to our API
const trackPerformanceToAPI = async () => {
try {
// 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
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);
}
}
}, 2000); // Wait 2 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);
}
// Track route changes (for SPA navigation) // Track route changes (for SPA navigation)
const handleRouteChange = () => { const handleRouteChange = () => {
setTimeout(() => { setTimeout(() => {
@@ -43,48 +142,84 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
// Track user interactions // Track user interactions
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement; try {
const element = target.tagName.toLowerCase(); if (typeof window === 'undefined') return;
const className = target.className;
const id = target.id; const target = event.target as HTMLElement | null;
if (!target) return;
trackEvent('click', {
element, const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined, const className = target.className;
id: id || undefined, const id = target.id;
url: window.location.pathname,
}); 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);
}
}
}; };
// Track form submissions // Track form submissions
const handleSubmit = (event: SubmitEvent) => { const handleSubmit = (event: SubmitEvent) => {
const form = event.target as HTMLFormElement; try {
trackEvent('form-submit', { if (typeof window === 'undefined') return;
formId: form.id || undefined,
formClass: form.className || undefined, const form = event.target as HTMLFormElement | null;
url: window.location.pathname, 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 // Track scroll depth
let maxScrollDepth = 0; let maxScrollDepth = 0;
const handleScroll = () => { const handleScroll = () => {
const scrollDepth = Math.round( try {
(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100 if (typeof window === 'undefined' || typeof document === 'undefined') return;
);
if (scrollDepth > maxScrollDepth) {
maxScrollDepth = scrollDepth;
// Track scroll milestones const scrollHeight = document.documentElement.scrollHeight;
if (scrollDepth >= 25 && scrollDepth < 50 && maxScrollDepth >= 25) { const innerHeight = window.innerHeight;
trackEvent('scroll-depth', { depth: 25, url: window.location.pathname });
} else if (scrollDepth >= 50 && scrollDepth < 75 && maxScrollDepth >= 50) { if (scrollHeight <= innerHeight) return; // No scrollable content
trackEvent('scroll-depth', { depth: 50, url: window.location.pathname });
} else if (scrollDepth >= 75 && scrollDepth < 90 && maxScrollDepth >= 75) { const scrollDepth = Math.round(
trackEvent('scroll-depth', { depth: 75, url: window.location.pathname }); (window.scrollY / (scrollHeight - innerHeight)) * 100
} else if (scrollDepth >= 90 && maxScrollDepth >= 90) { );
trackEvent('scroll-depth', { depth: 90, url: window.location.pathname });
if (scrollDepth > maxScrollDepth) {
maxScrollDepth = scrollDepth;
// Track scroll milestones
if (scrollDepth >= 25 && scrollDepth < 50 && maxScrollDepth >= 25) {
trackEvent('scroll-depth', { depth: 25, url: window.location.pathname });
} else if (scrollDepth >= 50 && scrollDepth < 75 && maxScrollDepth >= 50) {
trackEvent('scroll-depth', { depth: 50, url: window.location.pathname });
} else if (scrollDepth >= 75 && scrollDepth < 90 && maxScrollDepth >= 75) {
trackEvent('scroll-depth', { depth: 75, url: window.location.pathname });
} else if (scrollDepth >= 90 && maxScrollDepth >= 90) {
trackEvent('scroll-depth', { depth: 90, 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);
} }
} }
}; };
@@ -96,35 +231,64 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
// Track errors // Track errors
const handleError = (event: ErrorEvent) => { const handleError = (event: ErrorEvent) => {
trackEvent('error', { try {
message: event.message, if (typeof window === 'undefined') return;
filename: event.filename, trackEvent('error', {
lineno: event.lineno, message: event.message || 'Unknown error',
colno: event.colno, filename: event.filename || undefined,
url: window.location.pathname, 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) => { const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
trackEvent('unhandled-rejection', { try {
reason: event.reason?.toString(), if (typeof window === 'undefined') return;
url: window.location.pathname, 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('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection); window.addEventListener('unhandledrejection', handleUnhandledRejection);
// Cleanup // Cleanup
return () => { return () => {
window.removeEventListener('popstate', handleRouteChange); try {
document.removeEventListener('click', handleClick); window.removeEventListener('popstate', handleRouteChange);
document.removeEventListener('submit', handleSubmit); document.removeEventListener('click', handleClick);
window.removeEventListener('scroll', handleScroll); document.removeEventListener('submit', handleSubmit);
window.removeEventListener('error', handleError); window.removeEventListener('scroll', handleScroll);
window.removeEventListener('unhandledrejection', handleUnhandledRejection); 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 () => {};
}
}, []); }, []);
// Always render children, even if analytics fails
return <>{children}</>; return <>{children}</>;
}; };

View File

@@ -27,7 +27,16 @@ const BackgroundBlobs = () => {
const x5 = useTransform(springX, (value) => value / 15); const x5 = useTransform(springX, (value) => value / 15);
const y5 = useTransform(springY, (value) => value / 15); const y5 = useTransform(springY, (value) => value / 15);
// Prevent hydration mismatch
const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const x = e.clientX - window.innerWidth / 2; const x = e.clientX - window.innerWidth / 2;
const y = e.clientY - window.innerHeight / 2; const y = e.clientY - window.innerHeight / 2;
@@ -37,14 +46,7 @@ const BackgroundBlobs = () => {
window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove); return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY]); }, [mouseX, mouseY, mounted]);
// Prevent hydration mismatch
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null; if (!mounted) return null;

View File

@@ -143,7 +143,7 @@ export const EmailManager: React.FC = () => {
case 'high': return 'text-red-400'; case 'high': return 'text-red-400';
case 'medium': return 'text-yellow-400'; case 'medium': return 'text-yellow-400';
case 'low': return 'text-green-400'; case 'low': return 'text-green-400';
default: return 'text-blue-400'; default: return 'text-stone-400';
} }
}; };
@@ -153,7 +153,7 @@ export const EmailManager: React.FC = () => {
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full" className="w-8 h-8 border-2 border-stone-500 border-t-transparent rounded-full"
/> />
</div> </div>
); );
@@ -164,12 +164,12 @@ export const EmailManager: React.FC = () => {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold text-white">Email Manager</h2> <h2 className="text-2xl font-bold text-stone-900">Email Manager</h2>
<p className="text-white/70 mt-1">Manage your contact messages</p> <p className="text-stone-500 mt-1">Manage your contact messages</p>
</div> </div>
<button <button
onClick={loadMessages} onClick={loadMessages}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors" className="flex items-center space-x-2 px-4 py-2 bg-stone-100 text-stone-700 rounded-lg hover:bg-stone-200 transition-colors"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
<span>Refresh</span> <span>Refresh</span>
@@ -179,13 +179,13 @@ export const EmailManager: React.FC = () => {
{/* Filters and Search */} {/* Filters and Search */}
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/50 w-4 h-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-stone-400 w-4 h-4" />
<input <input
type="text" type="text"
placeholder="Search messages..." placeholder="Search messages..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400"
/> />
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
@@ -195,8 +195,8 @@ export const EmailManager: React.FC = () => {
onClick={() => setFilter(filterType as 'all' | 'unread' | 'responded')} onClick={() => setFilter(filterType as 'all' | 'unread' | 'responded')}
className={`px-4 py-2 rounded-lg transition-colors ${ className={`px-4 py-2 rounded-lg transition-colors ${
filter === filterType filter === filterType
? 'bg-blue-500 text-white' ? 'bg-stone-900 text-stone-50'
: 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-white border border-stone-200 text-stone-600 hover:bg-stone-50'
}`} }`}
> >
{filterType.charAt(0).toUpperCase() + filterType.slice(1)} {filterType.charAt(0).toUpperCase() + filterType.slice(1)}
@@ -209,7 +209,7 @@ export const EmailManager: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-3"> <div className="lg:col-span-1 space-y-3">
{filteredMessages.length === 0 ? ( {filteredMessages.length === 0 ? (
<div className="text-center py-12 text-white/50"> <div className="text-center py-12 text-stone-400">
<Mail className="w-12 h-12 mx-auto mb-4 opacity-50" /> <Mail className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No messages found</p> <p>No messages found</p>
</div> </div>
@@ -219,36 +219,36 @@ export const EmailManager: React.FC = () => {
key={message.id} key={message.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-lg cursor-pointer transition-all ${ className={`p-4 rounded-lg cursor-pointer transition-all border ${
selectedMessage?.id === message.id selectedMessage?.id === message.id
? 'bg-blue-500/20 border border-blue-500/50' ? 'bg-stone-100 border-stone-300 shadow-sm'
: 'bg-white/5 border border-white/10 hover:bg-white/10' : 'bg-white border-stone-200 hover:bg-stone-50'
}`} }`}
onClick={() => handleMessageClick(message)} onClick={() => handleMessageClick(message)}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-white truncate">{message.subject}</h3> <h3 className="font-semibold text-stone-900 truncate">{message.subject}</h3>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{!message.read && <Circle className="w-3 h-3 text-blue-400" />} {!message.read && <Circle className="w-3 h-3 text-stone-600" />}
{message.responded && <CheckCircle className="w-3 h-3 text-green-400" />} {message.responded && <CheckCircle className="w-3 h-3 text-green-500" />}
</div> </div>
</div> </div>
<p className="text-white/70 text-sm mb-2">{message.name}</p> <p className="text-stone-600 text-sm mb-2">{message.name}</p>
<p className="text-white/50 text-xs">{formatDate(message.createdAt)}</p> <p className="text-stone-400 text-xs">{formatDate(message.createdAt)}</p>
</motion.div> </motion.div>
)) ))
)} )}
</div> </div>
{/* Message Detail */} {/* Message Detail */}
<div className="lg:col-span-2 admin-glass-card p-6 rounded-xl"> <div className="lg:col-span-2 admin-glass-card p-6 rounded-xl bg-white border border-stone-200">
{selectedMessage ? ( {selectedMessage ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Message Header */} {/* Message Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xl font-bold text-white">{selectedMessage.subject}</h3> <h3 className="text-xl font-bold text-stone-900">{selectedMessage.subject}</h3>
<div className="flex items-center space-x-4 text-sm text-white/70"> <div className="flex items-center space-x-4 text-sm text-stone-500">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<User className="w-4 h-4" /> <User className="w-4 h-4" />
<span>{selectedMessage.name}</span> <span>{selectedMessage.name}</span>
@@ -264,15 +264,15 @@ export const EmailManager: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{!selectedMessage.read && <Circle className="w-4 h-4 text-blue-400" />} {!selectedMessage.read && <Circle className="w-4 h-4 text-stone-600" />}
{selectedMessage.responded && <CheckCircle className="w-4 h-4 text-green-400" />} {selectedMessage.responded && <CheckCircle className="w-4 h-4 text-green-500" />}
</div> </div>
</div> </div>
{/* Message Body */} {/* Message Body */}
<div className="p-4 bg-white/5 rounded-lg border border-white/10"> <div className="p-4 bg-stone-50 rounded-lg border border-stone-200">
<h4 className="text-white font-medium mb-3">Message:</h4> <h4 className="text-stone-700 font-medium mb-3">Message:</h4>
<div className="text-white/80 whitespace-pre-wrap leading-relaxed"> <div className="text-stone-600 whitespace-pre-wrap leading-relaxed">
{selectedMessage.message} {selectedMessage.message}
</div> </div>
</div> </div>
@@ -281,21 +281,21 @@ export const EmailManager: React.FC = () => {
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={() => setShowReplyModal(true)} onClick={() => setShowReplyModal(true)}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors" className="flex items-center space-x-2 px-4 py-2 bg-stone-900 text-stone-50 rounded-lg hover:bg-stone-800 transition-colors"
> >
<Reply className="w-4 h-4" /> <Reply className="w-4 h-4" />
<span>Reply</span> <span>Reply</span>
</button> </button>
<button <button
onClick={() => setSelectedMessage(null)} onClick={() => setSelectedMessage(null)}
className="px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors" className="px-4 py-2 bg-white border border-stone-200 text-stone-600 rounded-lg hover:bg-stone-50 transition-colors"
> >
Close Close
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-center py-12 text-white/50"> <div className="text-center py-12 text-stone-400">
<Eye className="w-12 h-12 mx-auto mb-4 opacity-50" /> <Eye className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Select a message to view details</p> <p>Select a message to view details</p>
</div> </div>
@@ -311,23 +311,23 @@ export const EmailManager: React.FC = () => {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4" className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={() => setShowReplyModal(false)} onClick={() => setShowReplyModal(false)}
> >
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }} exit={{ scale: 0.9, opacity: 0 }}
className="bg-gray-900/95 backdrop-blur-xl border border-white/20 rounded-2xl p-6 max-w-2xl w-full" className="bg-white border border-stone-200 rounded-2xl p-6 max-w-2xl w-full shadow-xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Reply to {selectedMessage.name}</h2> <h2 className="text-xl font-bold text-stone-900">Reply to {selectedMessage.name}</h2>
<button <button
onClick={() => setShowReplyModal(false)} onClick={() => setShowReplyModal(false)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 hover:bg-stone-100 rounded-lg transition-colors"
> >
<X className="w-5 h-5 text-white/70" /> <X className="w-5 h-5 text-stone-500" />
</button> </button>
</div> </div>
@@ -336,20 +336,20 @@ export const EmailManager: React.FC = () => {
value={replyContent} value={replyContent}
onChange={(e) => setReplyContent(e.target.value)} onChange={(e) => setReplyContent(e.target.value)}
placeholder="Type your reply..." placeholder="Type your reply..."
className="w-full h-32 p-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none" className="w-full h-32 p-3 bg-stone-50 border border-stone-200 rounded-lg text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400 resize-none"
/> />
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={handleReply} onClick={handleReply}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors" className="flex items-center space-x-2 px-4 py-2 bg-stone-900 text-stone-50 rounded-lg hover:bg-stone-800 transition-colors"
> >
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
<span>Send Reply</span> <span>Send Reply</span>
</button> </button>
<button <button
onClick={() => setShowReplyModal(false)} onClick={() => setShowReplyModal(false)}
className="px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors" className="px-4 py-2 bg-white border border-stone-200 text-stone-600 rounded-lg hover:bg-stone-50 transition-colors"
> >
Cancel Cancel
</button> </button>

View File

@@ -85,19 +85,19 @@ export const EmailResponder: React.FC<EmailResponderProps> = ({
return ( return (
<> <>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-stone-200">
{/* Header */} {/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-6 rounded-t-2xl"> <div className="bg-stone-50 border-b border-stone-200 text-stone-900 p-6 rounded-t-2xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold">📧 E-Mail Antwort senden</h2> <h2 className="text-2xl font-bold">📧 E-Mail Antwort senden</h2>
<p className="text-blue-100 mt-1">Wähle ein schönes Template für deine Antwort</p> <p className="text-stone-500 mt-1">Wähle ein schönes Template für deine Antwort</p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="text-white hover:text-gray-200 transition-colors" className="text-stone-400 hover:text-stone-600 transition-colors"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -110,54 +110,54 @@ export const EmailResponder: React.FC<EmailResponderProps> = ({
<div className="p-6"> <div className="p-6">
{/* Contact Info */} {/* Contact Info */}
<div className="bg-gray-50 rounded-xl p-4 mb-6"> <div className="bg-stone-50 border border-stone-200 rounded-xl p-4 mb-6">
<h3 className="font-semibold text-gray-800 mb-2">📬 Kontakt-Informationen</h3> <h3 className="font-semibold text-stone-800 mb-2">📬 Kontakt-Informationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<span className="text-sm text-gray-600">Name:</span> <span className="text-sm text-stone-500">Name:</span>
<p className="font-medium text-gray-900">{contactName}</p> <p className="font-medium text-stone-900">{contactName}</p>
</div> </div>
<div> <div>
<span className="text-sm text-gray-600">E-Mail:</span> <span className="text-sm text-stone-500">E-Mail:</span>
<p className="font-medium text-gray-900">{contactEmail}</p> <p className="font-medium text-stone-900">{contactEmail}</p>
</div> </div>
</div> </div>
</div> </div>
{/* Original Message Preview */} {/* Original Message Preview */}
<div className="bg-blue-50 rounded-xl p-4 mb-6"> <div className="bg-stone-50 border border-stone-200 rounded-xl p-4 mb-6">
<h3 className="font-semibold text-blue-800 mb-2">💬 Ursprüngliche Nachricht</h3> <h3 className="font-semibold text-stone-800 mb-2">💬 Ursprüngliche Nachricht</h3>
<div className="bg-white rounded-lg p-3 border-l-4 border-blue-500"> <div className="bg-white rounded-lg p-3 border-l-4 border-blue-500 shadow-sm">
<p className="text-gray-700 text-sm whitespace-pre-wrap">{originalMessage}</p> <p className="text-stone-700 text-sm whitespace-pre-wrap">{originalMessage}</p>
</div> </div>
</div> </div>
{/* Template Selection */} {/* Template Selection */}
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-800 mb-4">🎨 Template auswählen</h3> <h3 className="font-semibold text-stone-800 mb-4">🎨 Template auswählen</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.entries(templates).map(([key, template]) => ( {Object.entries(templates).map(([key, template]) => (
<div <div
key={key} key={key}
className={`relative cursor-pointer rounded-xl border-2 transition-all duration-200 ${ className={`relative cursor-pointer rounded-xl border-2 transition-all duration-200 ${
selectedTemplate === key selectedTemplate === key
? 'border-blue-500 bg-blue-50 shadow-lg scale-105' ? 'border-stone-500 bg-stone-50 shadow-md'
: 'border-gray-200 hover:border-gray-300 hover:shadow-md' : 'border-stone-200 hover:border-stone-300 hover:shadow-sm'
}`} }`}
onClick={() => setSelectedTemplate(key as keyof typeof templates)} onClick={() => setSelectedTemplate(key as keyof typeof templates)}
> >
<div className={`bg-gradient-to-r ${template.color} text-white p-4 rounded-t-xl`}> <div className={`p-4 rounded-t-xl bg-white border-b border-stone-100`}>
<div className="text-center"> <div className="text-center">
<div className="text-3xl mb-2">{template.icon}</div> <div className="text-3xl mb-2">{template.icon}</div>
<h4 className="font-bold text-lg">{template.name}</h4> <h4 className="font-bold text-lg text-stone-900">{template.name}</h4>
</div> </div>
</div> </div>
<div className="p-4"> <div className="p-4">
<p className="text-sm text-gray-600 text-center">{template.description}</p> <p className="text-sm text-stone-600 text-center">{template.description}</p>
</div> </div>
{selectedTemplate === key && ( {selectedTemplate === key && (
<div className="absolute top-2 right-2"> <div className="absolute top-2 right-2">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center"> <div className="w-6 h-6 bg-stone-600 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
@@ -171,15 +171,15 @@ export const EmailResponder: React.FC<EmailResponderProps> = ({
{/* Preview */} {/* Preview */}
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-800 mb-4">👀 Vorschau</h3> <h3 className="font-semibold text-stone-800 mb-4">👀 Vorschau</h3>
<div className="bg-gray-100 rounded-xl p-4"> <div className="bg-stone-100 rounded-xl p-4 border border-stone-200">
<div className="bg-white rounded-lg shadow-sm border"> <div className="bg-white rounded-lg shadow-sm border border-stone-200">
<div className={`bg-gradient-to-r ${templates[selectedTemplate].color} text-white p-4 rounded-t-lg`}> <div className="p-4 rounded-t-lg bg-stone-50 border-b border-stone-100">
<h4 className="font-bold text-lg">{templates[selectedTemplate].icon} {templates[selectedTemplate].name}</h4> <h4 className="font-bold text-lg text-stone-900">{templates[selectedTemplate].icon} {templates[selectedTemplate].name}</h4>
<p className="text-sm opacity-90">An: {contactName}</p> <p className="text-sm text-stone-500">An: {contactName}</p>
</div> </div>
<div className="p-4"> <div className="p-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-stone-600">
{selectedTemplate === 'welcome' && 'Freundliche Begrüßung mit Portfolio-Links und nächsten Schritten'} {selectedTemplate === 'welcome' && 'Freundliche Begrüßung mit Portfolio-Links und nächsten Schritten'}
{selectedTemplate === 'project' && 'Professionelle Projekt-Antwort mit Arbeitsprozess und CTA'} {selectedTemplate === 'project' && 'Professionelle Projekt-Antwort mit Arbeitsprozess und CTA'}
{selectedTemplate === 'quick' && 'Schnelle, kurze Bestätigung der Nachricht'} {selectedTemplate === 'quick' && 'Schnelle, kurze Bestätigung der Nachricht'}
@@ -193,14 +193,14 @@ export const EmailResponder: React.FC<EmailResponderProps> = ({
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-colors font-medium" className="flex-1 px-6 py-3 border border-stone-300 text-stone-700 rounded-xl hover:bg-stone-50 transition-colors font-medium"
> >
Abbrechen Abbrechen
</button> </button>
<button <button
onClick={handleSendEmail} onClick={handleSendEmail}
disabled={isLoading} disabled={isLoading}
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl hover:from-blue-700 hover:to-purple-700 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="flex-1 px-6 py-3 bg-stone-900 text-white rounded-xl hover:bg-stone-800 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{isLoading ? ( {isLoading ? (
<> <>

View File

@@ -22,17 +22,19 @@ export default class ErrorBoundary extends React.Component<
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( // Still render children to prevent white screen - just log the error
<div className="p-4 m-4 bg-red-50 border border-red-200 rounded text-red-800"> if (process.env.NODE_ENV === 'development') {
<h2>Something went wrong!</h2> return (
<button <div>
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" <div className="p-2 m-2 bg-yellow-50 border border-yellow-200 rounded text-yellow-800 text-xs">
onClick={() => this.setState({ hasError: false })} Error boundary triggered - rendering children anyway
> </div>
Try again {this.props.children}
</button> </div>
</div> );
); }
// In production, just render children silently
return this.props.children;
} }
return this.props.children; return this.props.children;

View File

@@ -99,23 +99,23 @@ export default function ImportExport() {
}; };
return ( return (
<div className="admin-glass-card rounded-lg p-6"> <div className="bg-white border border-stone-200 rounded-xl p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center"> <h3 className="text-lg font-semibold text-stone-900 mb-4 flex items-center">
<FileText className="w-5 h-5 mr-2 text-blue-400" /> <FileText className="w-5 h-5 mr-2 text-stone-600" />
Import & Export Import & Export
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Export Section */} {/* Export Section */}
<div className="admin-glass-light rounded-lg p-4"> <div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
<h4 className="font-medium text-white mb-2">Export Projekte</h4> <h4 className="font-medium text-stone-900 mb-2">Export Projekte</h4>
<p className="text-sm text-white/70 mb-3"> <p className="text-sm text-stone-600 mb-3">
Alle Projekte als JSON-Datei herunterladen Alle Projekte als JSON-Datei herunterladen
</p> </p>
<button <button
onClick={handleExport} onClick={handleExport}
disabled={isExporting} disabled={isExporting}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 hover:scale-105 transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center px-4 py-2 bg-stone-900 text-white rounded-lg hover:bg-stone-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
{isExporting ? 'Exportiere...' : 'Exportieren'} {isExporting ? 'Exportiere...' : 'Exportieren'}
@@ -123,12 +123,12 @@ export default function ImportExport() {
</div> </div>
{/* Import Section */} {/* Import Section */}
<div className="admin-glass-light rounded-lg p-4"> <div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
<h4 className="font-medium text-white mb-2">Import Projekte</h4> <h4 className="font-medium text-stone-900 mb-2">Import Projekte</h4>
<p className="text-sm text-white/70 mb-3"> <p className="text-sm text-stone-600 mb-3">
JSON-Datei mit Projekten hochladen JSON-Datei mit Projekten hochladen
</p> </p>
<label className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 hover:scale-105 transition-all cursor-pointer"> <label className="flex items-center px-4 py-2 bg-stone-900 text-white rounded-lg hover:bg-stone-800 transition-colors cursor-pointer w-fit">
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
{isImporting ? 'Importiere...' : 'Datei auswählen'} {isImporting ? 'Importiere...' : 'Datei auswählen'}
<input <input
@@ -143,16 +143,16 @@ export default function ImportExport() {
{/* Import Results */} {/* Import Results */}
{importResult && ( {importResult && (
<div className="admin-glass-light rounded-lg p-4"> <div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
<h4 className="font-medium text-white mb-2 flex items-center"> <h4 className="font-medium text-stone-900 mb-2 flex items-center">
{importResult.success ? ( {importResult.success ? (
<CheckCircle className="w-5 h-5 mr-2 text-green-400" /> <CheckCircle className="w-5 h-5 mr-2 text-green-600" />
) : ( ) : (
<AlertCircle className="w-5 h-5 mr-2 text-red-400" /> <AlertCircle className="w-5 h-5 mr-2 text-red-600" />
)} )}
Import Ergebnis Import Ergebnis
</h4> </h4>
<div className="text-sm text-white/70 space-y-1"> <div className="text-sm text-stone-600 space-y-1">
<p><strong>Importiert:</strong> {importResult.results.imported}</p> <p><strong>Importiert:</strong> {importResult.results.imported}</p>
<p><strong>Übersprungen:</strong> {importResult.results.skipped}</p> <p><strong>Übersprungen:</strong> {importResult.results.skipped}</p>
{importResult.results.errors.length > 0 && ( {importResult.results.errors.length > 0 && (
@@ -160,7 +160,7 @@ export default function ImportExport() {
<p><strong>Fehler:</strong></p> <p><strong>Fehler:</strong></p>
<ul className="list-disc list-inside ml-4"> <ul className="list-disc list-inside ml-4">
{importResult.results.errors.map((error, index) => ( {importResult.results.errors.map((error, index) => (
<li key={index} className="text-red-400">{error}</li> <li key={index} className="text-red-600">{error}</li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -157,14 +157,24 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
const stats = { const stats = {
totalProjects: projects.length, totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length, publishedProjects: projects.filter(p => p.published).length,
totalViews: (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0), totalViews: ((analytics?.overview as Record<string, unknown>)?.totalViews as number) || (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0),
unreadEmails: emails.filter(e => !(e.read as boolean)).length, unreadEmails: emails.filter(e => !(e.read as boolean)).length,
avgPerformance: (analytics?.avgPerformance as number) || (projects.length > 0 ? avgPerformance: (() => {
Math.round(projects.reduce((sum, p) => sum + (p.performance?.lighthouse || 90), 0) / projects.length) : 90), // Only show real performance data, not defaults
const projectsWithPerf = projects.filter(p => {
const perf = p.performance as Record<string, unknown> || {};
return (perf.lighthouse as number || 0) > 0;
});
if (projectsWithPerf.length === 0) return 0;
return Math.round(projectsWithPerf.reduce((sum, p) => {
const perf = p.performance as Record<string, unknown> || {};
return sum + (perf.lighthouse as number || 0);
}, 0) / projectsWithPerf.length);
})(),
systemHealth: (systemStats?.status as string) || 'unknown', systemHealth: (systemStats?.status as string) || 'unknown',
totalUsers: (analytics?.totalUsers as number) || 0, totalUsers: ((analytics?.metrics as Record<string, unknown>)?.totalUsers as number) || (analytics?.totalUsers as number) || 0,
bounceRate: (analytics?.bounceRate as number) || 0, bounceRate: ((analytics?.metrics as Record<string, unknown>)?.bounceRate as number) || (analytics?.bounceRate as number) || 0,
avgSessionDuration: (analytics?.avgSessionDuration as number) || 0 avgSessionDuration: ((analytics?.metrics as Record<string, unknown>)?.avgSessionDuration as number) || (analytics?.avgSessionDuration as number) || 0
}; };
useEffect(() => { useEffect(() => {
@@ -194,15 +204,15 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link <Link
href="/" href="/"
className="flex items-center space-x-2 text-white/90 hover:text-white transition-colors" className="flex items-center space-x-2 text-stone-900 hover:text-black transition-colors"
> >
<Home size={20} className="text-blue-400" /> <Home size={20} className="text-stone-600" />
<span className="font-medium text-white">Portfolio</span> <span className="font-medium text-stone-900">Portfolio</span>
</Link> </Link>
<div className="h-6 w-px bg-white/30" /> <div className="h-6 w-px bg-stone-300" />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Shield size={20} className="text-purple-400" /> <Shield size={20} className="text-stone-600" />
<span className="text-white font-semibold">Admin Panel</span> <span className="text-stone-900 font-semibold">Admin Panel</span>
</div> </div>
</div> </div>
@@ -214,20 +224,20 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
onClick={() => setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'settings')} onClick={() => setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'settings')}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 ${ className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 ${
activeTab === item.id activeTab === item.id
? 'admin-glass-light border border-blue-500/40 text-blue-300 shadow-lg' ? 'bg-stone-100 text-stone-900 font-medium shadow-sm border border-stone-200'
: 'text-white/80 hover:text-white hover:admin-glass-light' : 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
}`} }`}
> >
<item.icon size={16} className={activeTab === item.id ? 'text-blue-400' : 'text-white/70'} /> <item.icon size={16} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} />
<span className="font-medium text-sm">{item.label}</span> <span className="text-sm">{item.label}</span>
</button> </button>
))} ))}
</div> </div>
{/* Right side - User info and Logout */} {/* Right side - User info and Logout */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="hidden sm:block text-sm text-white/80"> <div className="hidden sm:block text-sm text-stone-500">
Welcome, <span className="text-white font-semibold">Dennis</span> Welcome, <span className="text-stone-800 font-semibold">Dennis</span>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
@@ -244,7 +254,7 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
window.location.href = '/manage'; window.location.href = '/manage';
} }
}} }}
className="flex items-center space-x-2 px-3 py-2 rounded-lg admin-glass-light hover:bg-red-500/20 text-red-300 hover:text-red-200 transition-all duration-200" className="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-red-50 text-stone-500 hover:text-red-600 transition-all duration-200 border border-transparent hover:border-red-100"
> >
<LogOut size={16} /> <LogOut size={16} />
<span className="hidden sm:inline text-sm font-medium">Logout</span> <span className="hidden sm:inline text-sm font-medium">Logout</span>
@@ -253,7 +263,7 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
{/* Mobile menu button */} {/* Mobile menu button */}
<button <button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden flex items-center justify-center p-2 rounded-lg admin-glass-light text-white hover:text-blue-300 transition-colors" className="md:hidden flex items-center justify-center p-2 rounded-lg text-stone-600 hover:bg-stone-100 transition-colors"
> >
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />} {mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
</button> </button>
@@ -268,7 +278,7 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="md:hidden border-t border-white/20 admin-glass-light" className="md:hidden border-t border-stone-200 bg-white"
> >
<div className="px-4 py-4 space-y-2"> <div className="px-4 py-4 space-y-2">
{navigation.map((item) => ( {navigation.map((item) => (
@@ -280,11 +290,11 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
}} }}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 ${ className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 ${
activeTab === item.id activeTab === item.id
? 'admin-glass-light border border-blue-500/40 text-blue-300 shadow-lg' ? 'bg-stone-100 text-stone-900 shadow-sm border border-stone-200'
: 'text-white/80 hover:text-white hover:admin-glass-light' : 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
}`} }`}
> >
<item.icon size={18} className={activeTab === item.id ? 'text-blue-400' : 'text-white/70'} /> <item.icon size={18} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} />
<div className="text-left"> <div className="text-left">
<div className="font-medium text-sm">{item.label}</div> <div className="font-medium text-sm">{item.label}</div>
<div className="text-xs opacity-70">{item.description}</div> <div className="text-xs opacity-70">{item.description}</div>
@@ -312,96 +322,114 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-white">Admin Dashboard</h1> <h1 className="text-3xl font-bold text-stone-900">Admin Dashboard</h1>
<p className="text-white/80 text-lg">Manage your portfolio and monitor performance</p> <p className="text-stone-500 text-lg">Manage your portfolio and monitor performance</p>
</div> </div>
</div> </div>
{/* Stats Grid - Mobile: 2x3, Desktop: 6x1 horizontal */} {/* Stats Grid - Mobile: 2x3, Desktop: 6x1 horizontal */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 md:gap-6"> <div className="grid grid-cols-2 md:grid-cols-6 gap-3 md:gap-6">
<div <div
className="admin-glass-light p-4 rounded-xl hover:scale-105 transition-all duration-200 cursor-pointer" className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('projects')} onClick={() => setActiveTab('projects')}
> >
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-white/80 text-xs md:text-sm font-medium">Projects</p> <p className="text-stone-500 text-xs md:text-sm font-medium">Projects</p>
<Database size={20} className="text-blue-400" /> <Database size={20} className="text-stone-400" />
</div> </div>
<p className="text-xl md:text-2xl font-bold text-white">{stats.totalProjects}</p> <p className="text-xl md:text-2xl font-bold text-stone-900">{stats.totalProjects}</p>
<p className="text-green-400 text-xs font-medium">{stats.publishedProjects} published</p> <p className="text-stone-600 text-xs font-medium">{stats.publishedProjects} published</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Total projects in your portfolio from the database. Shows published vs unpublished count.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div> </div>
</div> </div>
<div <div
className="admin-glass-light p-4 rounded-xl hover:scale-105 transition-all duration-200 cursor-pointer" className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('analytics')} onClick={() => setActiveTab('analytics')}
> >
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-white/80 text-xs md:text-sm font-medium">Page Views</p> <p className="text-stone-500 text-xs md:text-sm font-medium">Page Views</p>
<Activity size={20} className="text-purple-400" /> <Activity size={20} className="text-stone-400" />
</div> </div>
<p className="text-xl md:text-2xl font-bold text-white">{stats.totalViews.toLocaleString()}</p> <p className="text-xl md:text-2xl font-bold text-stone-900">{stats.totalViews.toLocaleString()}</p>
<p className="text-blue-400 text-xs font-medium">{stats.totalUsers} users</p> <p className="text-stone-600 text-xs font-medium">{stats.totalUsers} users</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Total page views from PageView table (last 30 days). Each visit is tracked with IP, user agent, and timestamp. Users = unique IP addresses.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div> </div>
</div> </div>
<div <div
className="admin-glass-light p-4 rounded-xl hover:scale-105 transition-all duration-200 cursor-pointer" className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none"
onClick={() => setActiveTab('emails')} onClick={() => setActiveTab('emails')}
> >
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-white/80 text-xs md:text-sm font-medium">Messages</p> <p className="text-stone-500 text-xs md:text-sm font-medium">Messages</p>
<Mail size={20} className="text-green-400" /> <Mail size={20} className="text-stone-400" />
</div> </div>
<p className="text-xl md:text-2xl font-bold text-white">{emails.length}</p> <p className="text-xl md:text-2xl font-bold text-stone-900">{emails.length}</p>
<p className="text-red-400 text-xs font-medium">{stats.unreadEmails} unread</p> <p className="text-red-500 text-xs font-medium">{stats.unreadEmails} unread</p>
</div> </div>
</div> </div>
<div <div
className="admin-glass-light p-4 rounded-xl hover:scale-105 transition-all duration-200 cursor-pointer" className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('analytics')} onClick={() => setActiveTab('analytics')}
> >
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-white/80 text-xs md:text-sm font-medium">Performance</p> <p className="text-stone-500 text-xs md:text-sm font-medium">Performance</p>
<TrendingUp size={20} className="text-orange-400" /> <TrendingUp size={20} className="text-stone-400" />
</div> </div>
<p className="text-xl md:text-2xl font-bold text-white">{stats.avgPerformance}</p> <p className="text-xl md:text-2xl font-bold text-stone-900">{stats.avgPerformance || 'N/A'}</p>
<p className="text-orange-400 text-xs font-medium">Lighthouse Score</p> <p className="text-stone-600 text-xs font-medium">Lighthouse Score</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
{stats.avgPerformance > 0
? "✅ REAL DATA: Average Lighthouse score (0-100) calculated from real Web Vitals (LCP, FCP, CLS, FID, TTFB) collected from actual page visits. Only averages projects with real performance data."
: "No performance data yet. Scores appear after visitors load pages and Web Vitals are tracked."}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div> </div>
</div> </div>
<div <div
className="admin-glass-light p-4 rounded-xl hover:scale-105 transition-all duration-200 cursor-pointer" className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('analytics')} onClick={() => setActiveTab('analytics')}
> >
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-white/80 text-xs md:text-sm font-medium">Bounce Rate</p> <p className="text-stone-500 text-xs md:text-sm font-medium">Bounce Rate</p>
<Users size={20} className="text-red-400" /> <Users size={20} className="text-stone-400" />
</div> </div>
<p className="text-xl md:text-2xl font-bold text-white">{stats.bounceRate}%</p> <p className="text-xl md:text-2xl font-bold text-stone-900">{stats.bounceRate}%</p>
<p className="text-red-400 text-xs font-medium">Exit rate</p> <p className="text-stone-600 text-xs font-medium">Exit rate</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Percentage of sessions with only 1 pageview (calculated from PageView records grouped by IP). Lower is better. Shows how many visitors leave after viewing just one page.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div> </div>
</div> </div>
<div <div
className="admin-glass-light p-4 rounded-xl hover:scale-105 transition-all duration-200 cursor-pointer" className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none"
onClick={() => setActiveTab('settings')} onClick={() => setActiveTab('settings')}
> >
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-white/80 text-xs md:text-sm font-medium">System</p> <p className="text-stone-500 text-xs md:text-sm font-medium">System</p>
<Shield size={20} className="text-green-400" /> <Shield size={20} className="text-stone-400" />
</div> </div>
<p className="text-xl md:text-2xl font-bold text-white">Online</p> <p className="text-xl md:text-2xl font-bold text-stone-900">Online</p>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<p className="text-green-400 text-xs font-medium">All systems operational</p> <p className="text-stone-600 text-xs font-medium">Operational</p>
</div> </div>
</div> </div>
</div> </div>
@@ -412,10 +440,10 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
{/* Recent Activity */} {/* Recent Activity */}
<div className="admin-glass-card p-6 rounded-xl md:col-span-2"> <div className="admin-glass-card p-6 rounded-xl md:col-span-2">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Recent Activity</h2> <h2 className="text-xl font-bold text-stone-900">Recent Activity</h2>
<button <button
onClick={() => loadAllData()} onClick={() => loadAllData()}
className="text-blue-400 hover:text-blue-300 text-sm font-medium px-3 py-1 admin-glass-light rounded-lg transition-colors" className="text-stone-500 hover:text-stone-800 text-sm font-medium px-3 py-1 bg-stone-100 rounded-lg transition-colors border border-stone-200"
> >
Refresh Refresh
</button> </button>
@@ -424,19 +452,19 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
{/* Mobile: vertical stack, Desktop: horizontal columns */} {/* Mobile: vertical stack, Desktop: horizontal columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-sm font-medium text-white/60 uppercase tracking-wider">Projects</h3> <h3 className="text-xs font-bold text-stone-400 uppercase tracking-wider">Projects</h3>
<div className="space-y-4"> <div className="space-y-4">
{projects.slice(0, 3).map((project) => ( {projects.slice(0, 3).map((project) => (
<div key={project.id} className="flex items-start space-x-3 p-4 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('projects')}> <div key={project.id} className="flex items-start space-x-3 p-4 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('projects')}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white font-medium text-sm truncate">{project.title}</p> <p className="text-stone-800 font-medium text-sm truncate">{project.title}</p>
<p className="text-white/60 text-xs">{project.published ? 'Published' : 'Draft'} {project.analytics?.views || 0} views</p> <p className="text-stone-500 text-xs">{project.published ? 'Published' : 'Draft'} {project.analytics?.views || 0} views</p>
<div className="flex items-center space-x-2 mt-2"> <div className="flex items-center space-x-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs ${project.published ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}> <span className={`px-2 py-1 rounded-full text-xs font-medium ${project.published ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>
{project.published ? 'Live' : 'Draft'} {project.published ? 'Live' : 'Draft'}
</span> </span>
{project.featured && ( {project.featured && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded-full text-xs">Featured</span> <span className="px-2 py-1 bg-stone-200 text-stone-700 rounded-full text-xs font-medium">Featured</span>
)} )}
</div> </div>
</div> </div>
@@ -446,19 +474,19 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-white/60 uppercase tracking-wider">Messages</h3> <h3 className="text-xs font-bold text-stone-400 uppercase tracking-wider">Messages</h3>
<div className="space-y-3"> <div className="space-y-3">
{emails.slice(0, 3).map((email, index) => ( {emails.slice(0, 3).map((email, index) => (
<div key={index} className="flex items-center space-x-3 p-3 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('emails')}> <div key={index} className="flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('emails')}>
<div className="w-8 h-8 bg-green-500/30 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 bg-stone-200 rounded-lg flex items-center justify-center flex-shrink-0">
<Mail size={14} className="text-green-400" /> <Mail size={14} className="text-stone-600" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white font-medium text-sm truncate">From {email.name as string}</p> <p className="text-stone-800 font-medium text-sm truncate">From {email.name as string}</p>
<p className="text-white/60 text-xs truncate">{(email.subject as string) || 'No subject'}</p> <p className="text-stone-500 text-xs truncate">{(email.subject as string) || 'No subject'}</p>
</div> </div>
{!(email.read as boolean) && ( {!(email.read as boolean) && (
<div className="w-2 h-2 bg-red-400 rounded-full flex-shrink-0"></div> <div className="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"></div>
)} )}
</div> </div>
))} ))}
@@ -469,70 +497,70 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
{/* Quick Actions */} {/* Quick Actions */}
<div className="admin-glass-card p-6 rounded-xl"> <div className="admin-glass-card p-6 rounded-xl">
<h2 className="text-xl font-bold text-white mb-6">Quick Actions</h2> <h2 className="text-xl font-bold text-stone-900 mb-6">Quick Actions</h2>
<div className="space-y-4"> <div className="space-y-4">
<button <button
onClick={() => window.location.href = '/editor'} onClick={() => window.location.href = '/editor'}
className="w-full flex items-center space-x-3 p-3 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 text-left group" className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
> >
<div className="w-10 h-10 bg-green-500/30 rounded-lg flex items-center justify-center group-hover:bg-green-500/40 transition-colors"> <div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Plus size={18} className="text-green-400" /> <Plus size={18} className="text-stone-600" />
</div> </div>
<div> <div>
<p className="text-white font-medium text-sm">Ghost Editor</p> <p className="text-stone-800 font-medium text-sm">Ghost Editor</p>
<p className="text-white/60 text-xs">Professional writing tool</p> <p className="text-stone-500 text-xs">Professional writing tool</p>
</div> </div>
</button> </button>
<button <button
onClick={() => setActiveTab('analytics')} onClick={() => setActiveTab('analytics')}
className="w-full flex items-center space-x-3 p-3 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 text-left group" className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
> >
<div className="w-10 h-10 bg-red-500/30 rounded-lg flex items-center justify-center group-hover:bg-red-500/40 transition-colors"> <div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Activity size={18} className="text-red-400" /> <Activity size={18} className="text-stone-600" />
</div> </div>
<div> <div>
<p className="text-white font-medium text-sm">Reset Analytics</p> <p className="text-stone-800 font-medium text-sm">Reset Analytics</p>
<p className="text-white/60 text-xs">Clear analytics data</p> <p className="text-stone-500 text-xs">Clear analytics data</p>
</div> </div>
</button> </button>
<button <button
onClick={() => setActiveTab('emails')} onClick={() => setActiveTab('emails')}
className="w-full flex items-center space-x-3 p-3 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 text-left group" className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
> >
<div className="w-10 h-10 bg-green-500/30 rounded-lg flex items-center justify-center group-hover:bg-green-500/40 transition-colors"> <div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Mail size={18} className="text-green-400" /> <Mail size={18} className="text-stone-600" />
</div> </div>
<div> <div>
<p className="text-white font-medium text-sm">View Messages</p> <p className="text-stone-800 font-medium text-sm">View Messages</p>
<p className="text-white/60 text-xs">{stats.unreadEmails} unread messages</p> <p className="text-stone-500 text-xs">{stats.unreadEmails} unread messages</p>
</div> </div>
</button> </button>
<button <button
onClick={() => setActiveTab('analytics')} onClick={() => setActiveTab('analytics')}
className="w-full flex items-center space-x-3 p-3 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 text-left group" className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
> >
<div className="w-10 h-10 bg-purple-500/30 rounded-lg flex items-center justify-center group-hover:bg-purple-500/40 transition-colors"> <div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<TrendingUp size={18} className="text-purple-400" /> <TrendingUp size={18} className="text-stone-600" />
</div> </div>
<div> <div>
<p className="text-white font-medium text-sm">Analytics</p> <p className="text-stone-800 font-medium text-sm">Analytics</p>
<p className="text-white/60 text-xs">View detailed statistics</p> <p className="text-stone-500 text-xs">View detailed statistics</p>
</div> </div>
</button> </button>
<button <button
onClick={() => setActiveTab('settings')} onClick={() => setActiveTab('settings')}
className="w-full flex items-center space-x-3 p-3 admin-glass-light rounded-lg hover:scale-[1.02] transition-all duration-200 text-left group" className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
> >
<div className="w-10 h-10 bg-gray-500/30 rounded-lg flex items-center justify-center group-hover:bg-gray-500/40 transition-colors"> <div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Settings size={18} className="text-gray-400" /> <Settings size={18} className="text-stone-600" />
</div> </div>
<div> <div>
<p className="text-white font-medium text-sm">Settings</p> <p className="text-stone-800 font-medium text-sm">Settings</p>
<p className="text-white/60 text-xs">System configuration</p> <p className="text-stone-500 text-xs">System configuration</p>
</div> </div>
</button> </button>
</div> </div>
@@ -545,8 +573,8 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold text-white">Project Management</h2> <h2 className="text-2xl font-bold text-stone-900">Project Management</h2>
<p className="text-white/70 mt-1">Manage your portfolio projects</p> <p className="text-stone-500 mt-1">Manage your portfolio projects</p>
</div> </div>
</div> </div>
@@ -565,39 +593,39 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
{activeTab === 'settings' && ( {activeTab === 'settings' && (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-white">System Settings</h1> <h1 className="text-2xl font-bold text-stone-900">System Settings</h1>
<p className="text-white/60">Manage system configuration and preferences</p> <p className="text-stone-500">Manage system configuration and preferences</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="admin-glass-card p-6 rounded-xl"> <div className="admin-glass-card p-6 rounded-xl">
<h2 className="text-xl font-bold text-white mb-4">Import / Export</h2> <h2 className="text-xl font-bold text-stone-900 mb-4">Import / Export</h2>
<p className="text-white/70 mb-4">Backup and restore your portfolio data</p> <p className="text-stone-500 mb-4">Backup and restore your portfolio data</p>
<ImportExport /> <ImportExport />
</div> </div>
<div className="admin-glass-card p-6 rounded-xl"> <div className="admin-glass-card p-6 rounded-xl">
<h2 className="text-xl font-bold text-white mb-4">System Status</h2> <h2 className="text-xl font-bold text-stone-900 mb-4">System Status</h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
<span className="text-white/80">Database</span> <span className="text-stone-600">Database</span>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-4 h-4 bg-green-400 rounded-full animate-pulse"></div> <div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-green-400 font-medium">Online</span> <span className="text-green-600 font-medium">Online</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
<span className="text-white/80">Redis Cache</span> <span className="text-stone-600">Redis Cache</span>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-4 h-4 bg-green-400 rounded-full animate-pulse"></div> <div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-green-400 font-medium">Online</span> <span className="text-green-600 font-medium">Online</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
<span className="text-white/80">API Services</span> <span className="text-stone-600">API Services</span>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-4 h-4 bg-green-400 rounded-full animate-pulse"></div> <div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-green-400 font-medium">Online</span> <span className="text-green-600 font-medium">Online</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -75,7 +75,7 @@ export const PerformanceDashboard: React.FC = () => {
setIsVisible(true); setIsVisible(true);
trackEvent('dashboard-toggle', { action: 'show' }); trackEvent('dashboard-toggle', { action: 'show' });
}} }}
className="fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-blue-700 transition-colors z-50" className="fixed bottom-4 right-4 bg-white text-stone-700 border border-stone-200 px-4 py-2 rounded-lg shadow-md hover:bg-stone-50 transition-colors z-50"
> >
📊 Performance 📊 Performance
</button> </button>

View File

@@ -52,7 +52,6 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all'); const [selectedCategory, setSelectedCategory] = useState<string>('all');
// Editor is now a separate page - no modal state needed
const categories = ['all', 'Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design']; const categories = ['all', 'Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
@@ -77,10 +76,6 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
} }
}; };
// closeEditor removed - editor is now separate page
// saveProject removed - editor is now separate page
const deleteProject = async (projectId: string) => { const deleteProject = async (projectId: string) => {
if (!confirm('Are you sure you want to delete this project?')) return; if (!confirm('Are you sure you want to delete this project?')) return;
@@ -100,9 +95,9 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
const getStatusColor = (project: Project) => { const getStatusColor = (project: Project) => {
if (project.published) { if (project.published) {
return project.featured ? 'text-purple-400 bg-purple-500/20' : 'text-green-400 bg-green-500/20'; return project.featured ? 'text-stone-700 bg-stone-200' : 'text-green-700 bg-green-100';
} }
return 'text-yellow-400 bg-yellow-500/20'; return 'text-yellow-700 bg-yellow-100';
}; };
const getStatusText = (project: Project) => { const getStatusText = (project: Project) => {
@@ -117,20 +112,20 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-white">Project Management</h1> <h1 className="text-3xl font-bold text-stone-900">Project Management</h1>
<p className="text-white/80">{projects.length} projects {projects.filter(p => p.published).length} published</p> <p className="text-stone-500">{projects.length} projects {projects.filter(p => p.published).length} published</p>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<button <button
onClick={onProjectsChange} onClick={onProjectsChange}
className="flex items-center space-x-2 px-4 py-2 admin-glass-light rounded-xl hover:scale-105 transition-all duration-200" className="flex items-center space-x-2 px-4 py-2 bg-stone-100 border border-stone-200 rounded-xl hover:bg-stone-200 transition-all duration-200"
> >
<RefreshCw className="w-4 h-4 text-blue-400" /> <RefreshCw className="w-4 h-4 text-stone-600" />
<span className="text-white font-medium">Refresh</span> <span className="text-stone-700 font-medium">Refresh</span>
</button> </button>
<button <button
onClick={() => openEditor()} onClick={() => openEditor()}
className="flex items-center space-x-2 px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl hover:scale-105 transition-all duration-200 shadow-lg" className="flex items-center space-x-2 px-6 py-2 bg-stone-900 text-white rounded-xl hover:bg-stone-800 transition-all duration-200 shadow-md"
> >
<Plus size={18} /> <Plus size={18} />
<span className="font-medium">New Project</span> <span className="font-medium">New Project</span>
@@ -142,13 +137,13 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
{/* Search */} {/* Search */}
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/60" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-stone-400" />
<input <input
type="text" type="text"
placeholder="Search projects..." placeholder="Search projects..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 admin-glass-light border border-white/30 rounded-xl text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full pl-10 pr-4 py-3 bg-white border border-stone-200 rounded-xl text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400"
/> />
</div> </div>
@@ -156,23 +151,23 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
<select <select
value={selectedCategory} value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)} onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-3 admin-glass-light border border-white/30 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500 bg-transparent" className="px-4 py-3 bg-white border border-stone-200 rounded-xl text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-400"
> >
{categories.map(category => ( {categories.map(category => (
<option key={category} value={category} className="bg-gray-800"> <option key={category} value={category} className="bg-white text-stone-900">
{category === 'all' ? 'All Categories' : category} {category === 'all' ? 'All Categories' : category}
</option> </option>
))} ))}
</select> </select>
{/* View Toggle */} {/* View Toggle */}
<div className="flex items-center space-x-1 admin-glass-light rounded-xl p-1"> <div className="flex items-center space-x-1 bg-white border border-stone-200 rounded-xl p-1">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-all duration-200 ${ className={`p-2 rounded-lg transition-all duration-200 ${
viewMode === 'grid' viewMode === 'grid'
? 'bg-blue-500/40 text-blue-300' ? 'bg-stone-100 text-stone-900'
: 'text-white/70 hover:text-white hover:bg-white/10' : 'text-stone-400 hover:text-stone-900 hover:bg-stone-50'
}`} }`}
> >
<Grid className="w-4 h-4" /> <Grid className="w-4 h-4" />
@@ -181,8 +176,8 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-all duration-200 ${ className={`p-2 rounded-lg transition-all duration-200 ${
viewMode === 'list' viewMode === 'list'
? 'bg-blue-500/40 text-blue-300' ? 'bg-stone-100 text-stone-900'
: 'text-white/70 hover:text-white hover:bg-white/10' : 'text-stone-400 hover:text-stone-900 hover:bg-stone-50'
}`} }`}
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
@@ -198,24 +193,24 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
key={project.id} key={project.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="admin-glass-card p-6 rounded-xl hover:scale-105 transition-all duration-300 group" className="admin-glass-card p-6 rounded-xl hover:shadow-lg transition-all duration-300 group bg-white border border-stone-200"
> >
{/* Project Header */} {/* Project Header */}
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-bold text-white mb-1">{project.title}</h3> <h3 className="text-xl font-bold text-stone-900 mb-1">{project.title}</h3>
<p className="text-white/70 text-sm">{project.category}</p> <p className="text-stone-500 text-sm">{project.category}</p>
</div> </div>
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => openEditor(project)} onClick={() => openEditor(project)}
className="p-2 text-white/70 hover:text-blue-400 hover:bg-white/10 rounded-lg transition-colors" className="p-2 text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded-lg transition-colors"
> >
<Edit size={16} /> <Edit size={16} />
</button> </button>
<button <button
onClick={() => deleteProject(project.id)} onClick={() => deleteProject(project.id)}
className="p-2 text-white/70 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors" className="p-2 text-stone-500 hover:text-red-600 hover:bg-stone-100 rounded-lg transition-colors"
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
@@ -225,7 +220,7 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
{/* Project Content */} {/* Project Content */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<p className="text-white/70 text-sm line-clamp-2 leading-relaxed">{project.description}</p> <p className="text-stone-600 text-sm line-clamp-2 leading-relaxed">{project.description}</p>
</div> </div>
{/* Tags */} {/* Tags */}
@@ -234,13 +229,13 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
{project.tags.slice(0, 3).map((tag) => ( {project.tags.slice(0, 3).map((tag) => (
<span <span
key={tag} key={tag}
className="px-2 py-1 bg-white/10 text-white/70 rounded-full text-xs" className="px-2 py-1 bg-stone-100 text-stone-600 border border-stone-200 rounded-full text-xs"
> >
{tag} {tag}
</span> </span>
))} ))}
{project.tags.length > 3 && ( {project.tags.length > 3 && (
<span className="px-2 py-1 bg-white/10 text-white/70 rounded-full text-xs"> <span className="px-2 py-1 bg-stone-100 text-stone-600 border border-stone-200 rounded-full text-xs">
+{project.tags.length - 3} +{project.tags.length - 3}
</span> </span>
)} )}
@@ -258,7 +253,7 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
href={project.github} href={project.github}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-1 text-white/60 hover:text-white transition-colors" className="p-1 text-stone-400 hover:text-stone-900 transition-colors"
> >
<Github size={14} /> <Github size={14} />
</a> </a>
@@ -268,7 +263,7 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
href={project.live} href={project.live}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-1 text-white/60 hover:text-white transition-colors" className="p-1 text-stone-400 hover:text-stone-900 transition-colors"
> >
<Globe size={14} /> <Globe size={14} />
</a> </a>
@@ -277,18 +272,18 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
</div> </div>
{/* Analytics */} {/* Analytics */}
<div className="grid grid-cols-3 gap-2 pt-3 border-t border-white/10"> <div className="grid grid-cols-3 gap-2 pt-3 border-t border-stone-100">
<div className="text-center"> <div className="text-center">
<p className="text-white font-bold text-sm">{project.analytics?.views || 0}</p> <p className="text-stone-900 font-bold text-sm">{project.analytics?.views || 0}</p>
<p className="text-white/60 text-xs">Views</p> <p className="text-stone-500 text-xs">Views</p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-white font-bold text-sm">{project.analytics?.likes || 0}</p> <p className="text-stone-900 font-bold text-sm">{project.analytics?.likes || 0}</p>
<p className="text-white/60 text-xs">Likes</p> <p className="text-stone-500 text-xs">Likes</p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-white font-bold text-sm">{project.performance?.lighthouse || 90}</p> <p className="text-stone-900 font-bold text-sm">{project.performance?.lighthouse || 90}</p>
<p className="text-white/60 text-xs">Score</p> <p className="text-stone-500 text-xs">Score</p>
</div> </div>
</div> </div>
</div> </div>
@@ -302,13 +297,13 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
key={project.id} key={project.id}
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
className="admin-glass-card p-6 rounded-xl hover:scale-[1.01] transition-all duration-300 group" className="admin-glass-card p-6 rounded-xl hover:shadow-md transition-all duration-300 group bg-white border border-stone-200"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex-1"> <div className="flex-1">
<h3 className="text-white font-bold text-lg">{project.title}</h3> <h3 className="text-stone-900 font-bold text-lg">{project.title}</h3>
<p className="text-white/70 text-sm">{project.category}</p> <p className="text-stone-500 text-sm">{project.category}</p>
</div> </div>
</div> </div>
@@ -316,7 +311,7 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(project)}`}> <span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(project)}`}>
{getStatusText(project)} {getStatusText(project)}
</span> </span>
<div className="flex items-center space-x-3 text-white/60 text-sm"> <div className="flex items-center space-x-3 text-stone-500 text-sm">
<span>{project.analytics?.views || 0} views</span> <span>{project.analytics?.views || 0} views</span>
<span></span> <span></span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span> <span>{new Date(project.updatedAt).toLocaleDateString()}</span>
@@ -324,13 +319,13 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => openEditor(project)} onClick={() => openEditor(project)}
className="p-2 text-white/70 hover:text-blue-400 hover:bg-white/10 rounded-lg transition-colors" className="p-2 text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded-lg transition-colors"
> >
<Edit size={16} /> <Edit size={16} />
</button> </button>
<button <button
onClick={() => deleteProject(project.id)} onClick={() => deleteProject(project.id)}
className="p-2 text-white/70 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors" className="p-2 text-stone-500 hover:text-red-600 hover:bg-stone-100 rounded-lg transition-colors"
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
@@ -341,8 +336,6 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
))} ))}
</div> </div>
)} )}
{/* Editor is now a separate page at /editor */}
</div> </div>
); );
}; };

View File

@@ -34,8 +34,8 @@ const ToastItem = ({ toast, onRemove }: ToastProps) => {
useEffect(() => { useEffect(() => {
if (toast.duration !== 0) { if (toast.duration !== 0) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setTimeout(() => onRemove(toast.id), 300); setTimeout(() => onRemove(toast.id), 200);
}, toast.duration || 5000); }, toast.duration || 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
@@ -50,48 +50,48 @@ const ToastItem = ({ toast, onRemove }: ToastProps) => {
case 'warning': case 'warning':
return <AlertTriangle className="w-5 h-5 text-yellow-400" />; return <AlertTriangle className="w-5 h-5 text-yellow-400" />;
case 'info': case 'info':
return <Info className="w-5 h-5 text-blue-400" />; return <Info className="w-5 h-5 text-stone-400" />;
default: default:
return <Info className="w-5 h-5 text-blue-400" />; return <Info className="w-5 h-5 text-stone-400" />;
} }
}; };
const getColors = () => { const getColors = () => {
switch (toast.type) { switch (toast.type) {
case 'success': case 'success':
return 'bg-white border-green-300 text-green-900 shadow-lg'; return 'bg-stone-50 border-green-300 text-green-900 shadow-md';
case 'error': case 'error':
return 'bg-white border-red-300 text-red-900 shadow-lg'; return 'bg-stone-50 border-red-200 text-red-800 shadow-md';
case 'warning': case 'warning':
return 'bg-white border-yellow-300 text-yellow-900 shadow-lg'; return 'bg-stone-50 border-yellow-200 text-yellow-800 shadow-md';
case 'info': case 'info':
return 'bg-white border-blue-300 text-blue-900 shadow-lg'; return 'bg-stone-50 border-stone-200 text-stone-800 shadow-md';
default: default:
return 'bg-white border-gray-300 text-gray-900 shadow-lg'; return 'bg-stone-50 border-stone-200 text-stone-800 shadow-md';
} }
}; };
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }} initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }} exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.2, ease: "easeOut" }}
className={`relative p-4 rounded-xl border ${getColors()} shadow-xl hover:shadow-2xl transition-all duration-300 max-w-sm`} className={`relative p-3 rounded-lg border ${getColors()} shadow-lg hover:shadow-xl transition-all duration-200 max-w-xs text-sm`}
> >
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-2">
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 mt-0.5">
{getIcon()} {getIcon()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold mb-1">{toast.title}</h4> <h4 className="text-xs font-semibold mb-0.5 leading-tight">{toast.title}</h4>
<p className="text-sm opacity-90">{toast.message}</p> <p className="text-xs opacity-90 leading-tight">{toast.message}</p>
{toast.action && ( {toast.action && (
<button <button
onClick={toast.action.onClick} onClick={toast.action.onClick}
className="mt-2 text-xs font-medium underline hover:no-underline transition-all" className="mt-1.5 text-xs font-medium underline hover:no-underline transition-all"
> >
{toast.action.label} {toast.action.label}
</button> </button>
@@ -100,9 +100,9 @@ const ToastItem = ({ toast, onRemove }: ToastProps) => {
<button <button
onClick={() => onRemove(toast.id)} onClick={() => onRemove(toast.id)}
className="flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors" className="flex-shrink-0 p-0.5 rounded hover:bg-gray-100/50 transition-colors"
> >
<X className="w-4 h-4 text-gray-500" /> <X className="w-3 h-3 text-gray-500" />
</button> </button>
</div> </div>
@@ -111,8 +111,8 @@ const ToastItem = ({ toast, onRemove }: ToastProps) => {
<motion.div <motion.div
initial={{ width: '100%' }} initial={{ width: '100%' }}
animate={{ width: '0%' }} animate={{ width: '0%' }}
transition={{ duration: (toast.duration || 5000) / 1000, ease: "linear" }} transition={{ duration: (toast.duration || 3000) / 1000, ease: "linear" }}
className="absolute bottom-0 left-0 h-1 bg-gradient-to-r from-blue-400 to-green-400 rounded-b-xl" className="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-stone-400 to-stone-600 rounded-b-lg"
/> />
)} )}
</motion.div> </motion.div>
@@ -139,10 +139,27 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType | undefined>(undefined); const ToastContext = createContext<ToastContextType | undefined>(undefined);
// No-op fallback for SSR or when outside provider
const noopToast: ToastContextType = {
addToast: () => {},
showToast: () => {},
showSuccess: () => {},
showError: () => {},
showWarning: () => {},
showInfo: () => {},
showEmailSent: () => {},
showEmailError: () => {},
showProjectSaved: () => {},
showProjectDeleted: () => {},
showImportSuccess: () => {},
showImportError: () => {},
};
export const useToast = () => { export const useToast = () => {
const context = useContext(ToastContext); const context = useContext(ToastContext);
// Return no-op fallback during SSR or if used outside provider
if (!context) { if (!context) {
throw new Error('useToast must be used within a ToastProvider'); return noopToast;
} }
return context; return context;
}; };
@@ -178,7 +195,7 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
type: 'error', type: 'error',
title, title,
message: message || '', message: message || '',
duration: 6000 duration: 4000 // Shorter duration
}); });
}, [addToast]); }, [addToast]);

View File

@@ -57,34 +57,55 @@ export const trackWebVitals = (metric: WebVitalsMetric) => {
// Track page load performance // Track page load performance
export const trackPageLoad = () => { export const trackPageLoad = () => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined' || typeof performance === 'undefined') return;
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; try {
const navigationEntries = performance.getEntriesByType('navigation');
if (navigation) { const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
trackPerformance({
name: 'page-load', if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
value: navigation.loadEventEnd - navigation.fetchStart, trackPerformance({
url: window.location.pathname, name: 'page-load',
timestamp: Date.now(), value: navigation.loadEventEnd - navigation.fetchStart,
userAgent: navigator.userAgent, url: window.location.pathname,
}); timestamp: Date.now(),
userAgent: navigator.userAgent,
});
// Track individual timing phases // Track individual timing phases
trackEvent('page-timing', { trackEvent('page-timing', {
dns: Math.round(navigation.domainLookupEnd - navigation.domainLookupStart), dns: navigation.domainLookupEnd && navigation.domainLookupStart
tcp: Math.round(navigation.connectEnd - navigation.connectStart), ? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
request: Math.round(navigation.responseStart - navigation.requestStart), : 0,
response: Math.round(navigation.responseEnd - navigation.responseStart), tcp: navigation.connectEnd && navigation.connectStart
dom: Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd), ? Math.round(navigation.connectEnd - navigation.connectStart)
load: Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd), : 0,
url: window.location.pathname, 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 // Track API response times
export const trackApiCall = (endpoint: string, duration: number, status: number) => { export const trackApiCall = (endpoint: string, duration: number, status: number) => {
if (typeof window === 'undefined') return;
trackEvent('api-call', { trackEvent('api-call', {
endpoint, endpoint,
duration: Math.round(duration), duration: Math.round(duration),
@@ -95,6 +116,7 @@ export const trackApiCall = (endpoint: string, duration: number, status: number)
// Track user interactions // Track user interactions
export const trackInteraction = (action: string, element?: string) => { export const trackInteraction = (action: string, element?: string) => {
if (typeof window === 'undefined') return;
trackEvent('interaction', { trackEvent('interaction', {
action, action,
element, element,
@@ -104,6 +126,7 @@ export const trackInteraction = (action: string, element?: string) => {
// Track errors // Track errors
export const trackError = (error: string, context?: string) => { export const trackError = (error: string, context?: string) => {
if (typeof window === 'undefined') return;
trackEvent('error', { trackEvent('error', {
error, error,
context, context,

View File

@@ -13,104 +13,192 @@ interface Metric {
// Simple Web Vitals implementation (since we don't want to add external dependencies) // Simple Web Vitals implementation (since we don't want to add external dependencies)
const getCLS = (onPerfEntry: (metric: Metric) => void) => { const getCLS = (onPerfEntry: (metric: Metric) => void) => {
let clsValue = 0; if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = []; try {
let clsValue = 0;
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) { try {
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) { for (const entry of list.getEntries()) {
const firstSessionEntry = sessionEntries[0]; if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) { if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0; sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
sessionEntries.push(entry); sessionEntries.push(entry);
} else { } else {
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0; sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
sessionEntries = [entry]; sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
onPerfEntry({
name: 'CLS',
value: clsValue,
delta: clsValue,
id: `cls-${Date.now()}`,
});
}
}
} }
} catch (error) {
if (sessionValue > clsValue) { // Silently fail - CLS tracking is not critical
clsValue = sessionValue; if (process.env.NODE_ENV === 'development') {
onPerfEntry({ console.warn('CLS tracking error:', error);
name: 'CLS',
value: clsValue,
delta: clsValue,
id: `cls-${Date.now()}`,
});
} }
} }
} });
});
observer.observe({ type: 'layout-shift', buffered: true }); 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) => { const getFID = (onPerfEntry: (metric: Metric) => void) => {
const observer = new PerformanceObserver((list) => { if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
for (const entry of list.getEntries()) {
onPerfEntry({ try {
name: 'FID', const observer = new PerformanceObserver((list) => {
value: (entry as PerformanceEntry & { processingStart?: number }).processingStart! - entry.startTime, try {
delta: (entry as PerformanceEntry & { processingStart?: number }).processingStart! - entry.startTime, for (const entry of list.getEntries()) {
id: `fid-${Date.now()}`, 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 }); 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) => { const getFCP = (onPerfEntry: (metric: Metric) => void) => {
const observer = new PerformanceObserver((list) => { if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') { try {
onPerfEntry({ const observer = new PerformanceObserver((list) => {
name: 'FCP', try {
value: entry.startTime, for (const entry of list.getEntries()) {
delta: entry.startTime, if (entry.name === 'first-contentful-paint') {
id: `fcp-${Date.now()}`, 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 }); 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) => { const getLCP = (onPerfEntry: (metric: Metric) => void) => {
const observer = new PerformanceObserver((list) => { if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1]; try {
const observer = new PerformanceObserver((list) => {
onPerfEntry({ try {
name: 'LCP', const entries = list.getEntries();
value: lastEntry.startTime, const lastEntry = entries[entries.length - 1];
delta: lastEntry.startTime,
id: `lcp-${Date.now()}`, 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 }); 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) => { const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
const observer = new PerformanceObserver((list) => { if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') { try {
const navEntry = entry as PerformanceNavigationTiming; const observer = new PerformanceObserver((list) => {
onPerfEntry({ try {
name: 'TTFB', for (const entry of list.getEntries()) {
value: navEntry.responseStart - navEntry.fetchStart, if (entry.entryType === 'navigation') {
delta: navEntry.responseStart - navEntry.fetchStart, const navEntry = entry as PerformanceNavigationTiming;
id: `ttfb-${Date.now()}`, 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 }); 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 // Custom hook for Web Vitals tracking
@@ -118,46 +206,101 @@ export const useWebVitals = () => {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
// Wrap everything in try-catch to prevent errors from breaking the app
try {
// Store web vitals for batch sending
const webVitals: Record<string, number> = {};
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: performance.now()
}
})
});
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.error('Error sending web vitals:', error);
}
}
}
};
// Track Core Web Vitals // Track Core Web Vitals
getCLS((metric) => { const clsObserver = getCLS((metric) => {
webVitals.CLS = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname, url: window.location.pathname,
}); });
sendWebVitals();
}); });
if (clsObserver) observers.push(clsObserver);
getFID((metric) => { const fidObserver = getFID((metric) => {
webVitals.FID = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname, url: window.location.pathname,
}); });
sendWebVitals();
}); });
if (fidObserver) observers.push(fidObserver);
getFCP((metric) => { const fcpObserver = getFCP((metric) => {
webVitals.FCP = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname, url: window.location.pathname,
}); });
sendWebVitals();
}); });
if (fcpObserver) observers.push(fcpObserver);
getLCP((metric) => { const lcpObserver = getLCP((metric) => {
webVitals.LCP = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname, url: window.location.pathname,
}); });
sendWebVitals();
}); });
if (lcpObserver) observers.push(lcpObserver);
getTTFB((metric) => { const ttfbObserver = getTTFB((metric) => {
webVitals.TTFB = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB', name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname, url: window.location.pathname,
}); });
sendWebVitals();
}); });
if (ttfbObserver) observers.push(ttfbObserver);
// Track page load performance // Track page load performance
const handleLoad = () => { const handleLoad = () => {
@@ -178,8 +321,28 @@ export const useWebVitals = () => {
window.addEventListener('load', handleLoad); window.addEventListener('load', handleLoad);
} }
return () => { return () => {
window.removeEventListener('load', handleLoad); // 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 () => {};
}
}, []); }, []);
}; };

View File

@@ -29,9 +29,14 @@ const nextConfig: NextConfig = {
}, },
// Performance optimizations // Performance optimizations
experimental: { // NOTE: `optimizePackageImports` can cause dev-time webpack runtime issues with some setups.
optimizePackageImports: ["lucide-react", "framer-motion"], // Keep it enabled for production builds only.
}, experimental:
process.env.NODE_ENV === "production"
? {
optimizePackageImports: ["lucide-react", "framer-motion"],
}
: {},
// Image optimization // Image optimization
images: { images: {
@@ -54,7 +59,7 @@ const nextConfig: NextConfig = {
}, },
// Webpack configuration // Webpack configuration
webpack: (config, { isServer, dev, webpack }) => { webpack: (config) => {
// Fix for module resolution issues // Fix for module resolution issues
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,
@@ -63,24 +68,6 @@ const nextConfig: NextConfig = {
tls: false, tls: false,
}; };
// Safari + React 19 + Next.js 15 compatibility fixes
if (dev && !isServer) {
// Disable module concatenation to prevent factory initialization issues
config.optimization = {
...config.optimization,
concatenateModules: false,
providedExports: false,
usedExports: false,
};
// Add DefinePlugin to ensure proper environment detection
config.plugins.push(
new webpack.DefinePlugin({
"process.env.__NEXT_DISABLE_REACT_STRICT_MODE": JSON.stringify(false),
}),
);
}
return config; return config;
}, },

1700
public/404-terminal.html Normal file

File diff suppressed because it is too large Load Diff