huge update

This commit is contained in:
2025-09-10 10:59:14 +02:00
parent be01ee2adb
commit 2f40fc6753
15 changed files with 729 additions and 301 deletions

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ToastProvider } from '@/components/Toast';
// Simple test component
const TestComponent = () => {
return (
<div>
<h1>Toast Test</h1>
</div>
);
};
const renderWithToast = (component: React.ReactElement) => {
return render(
<ToastProvider>
{component}
</ToastProvider>
);
};
describe('Toast Component', () => {
it('renders ToastProvider without crashing', () => {
renderWithToast(<TestComponent />);
expect(screen.getByText('Toast Test')).toBeInTheDocument();
});
it('provides toast context', () => {
// Simple test to ensure the provider works
const { container } = renderWithToast(<TestComponent />);
expect(container).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { apiCache } from '@/lib/cache';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -35,20 +36,41 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
// Check if this is an admin request
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
return NextResponse.json(
{ error: 'Admin access required' },
{ status: 403 }
);
}
const { id: idParam } = await params; const { id: idParam } = await params;
const id = parseInt(idParam); const id = parseInt(idParam);
const data = await request.json(); const data = await request.json();
// Remove difficulty field if it exists (since we're removing it)
const { difficulty, ...projectData } = data;
const project = await prisma.project.update({ const project = await prisma.project.update({
where: { id }, where: { id },
data: { ...data, updatedAt: new Date() } data: {
...projectData,
updatedAt: new Date(),
// Keep existing difficulty if not provided
...(difficulty ? { difficulty } : {})
}
}); });
// Invalidate cache after successful update
await apiCache.invalidateProject(id);
await apiCache.invalidateAll();
return NextResponse.json(project); return NextResponse.json(project);
} catch (error) { } catch (error) {
console.error('Error updating project:', error); console.error('Error updating project:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to update project' }, { error: 'Failed to update project', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 } { status: 500 }
); );
} }
@@ -66,6 +88,10 @@ export async function DELETE(
where: { id } where: { id }
}); });
// Invalidate cache after successful deletion
await apiCache.invalidateProject(id);
await apiCache.invalidateAll();
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error deleting project:', error); console.error('Error deleting project:', error);

View File

@@ -37,8 +37,19 @@ export async function GET(request: NextRequest) {
const difficulty = searchParams.get('difficulty'); const difficulty = searchParams.get('difficulty');
const search = searchParams.get('search'); const search = searchParams.get('search');
// Create cache parameters object
const cacheParams = {
page: page.toString(),
limit: limit.toString(),
category,
featured,
published,
difficulty,
search
};
// Check cache first // Check cache first
const cached = await apiCache.getProjects(); const cached = await apiCache.getProjects(cacheParams);
if (cached && !search) { // Don't cache search results if (cached && !search) { // Don't cache search results
return NextResponse.json(cached); return NextResponse.json(cached);
} }
@@ -80,7 +91,7 @@ export async function GET(request: NextRequest) {
// Cache the result (only for non-search queries) // Cache the result (only for non-search queries)
if (!search) { if (!search) {
await apiCache.setProjects(result); await apiCache.setProjects(cacheParams, result);
} }
return NextResponse.json(result); return NextResponse.json(result);
@@ -95,11 +106,26 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Check if this is an admin request
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
return NextResponse.json(
{ error: 'Admin access required' },
{ status: 403 }
);
}
const data = await request.json(); const data = await request.json();
// Remove difficulty field if it exists (since we're removing it)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { difficulty, ...projectData } = data;
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { data: {
...data, ...projectData,
// Set default difficulty since it's required in schema
difficulty: 'INTERMEDIATE',
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' }, performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },
analytics: data.analytics || { views: 0, likes: 0, shares: 0 } analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
} }
@@ -112,7 +138,7 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Error creating project:', error); console.error('Error creating project:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to create project' }, { error: 'Failed to create project', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Mail, Phone, MapPin, Send } from 'lucide-react'; import { Mail, MapPin, Send } from 'lucide-react';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
const Contact = () => { const Contact = () => {
@@ -69,12 +69,6 @@ const Contact = () => {
value: 'contact@dk0.dev', value: 'contact@dk0.dev',
href: 'mailto:contact@dk0.dev' href: 'mailto:contact@dk0.dev'
}, },
{
icon: Phone,
title: 'Phone',
value: '+49 176 12669990',
href: 'tel:+4917612669990'
},
{ {
icon: MapPin, icon: MapPin,
title: 'Location', title: 'Location',

View File

@@ -1,27 +1,26 @@
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback, Suspense } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
Eye, Eye,
Plus,
X, X,
Bold, Bold,
Italic, Italic,
Code, Code,
Image, Image,
Link, Link,
Type,
List, List,
ListOrdered, ListOrdered,
Quote, Quote,
Hash, Hash,
Loader2, Loader2,
Upload, ExternalLink,
Check Github,
Tag
} from 'lucide-react'; } from 'lucide-react';
interface Project { interface Project {
@@ -30,7 +29,6 @@ interface Project {
description: string; description: string;
content?: string; content?: string;
category: string; category: string;
difficulty?: string;
tags?: string[]; tags?: string[];
featured: boolean; featured: boolean;
published: boolean; published: boolean;
@@ -41,17 +39,18 @@ interface Project {
updatedAt: string; updatedAt: string;
} }
export default function EditorPage() { 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 [project, setProject] = useState<Project | null>(null); const [, setProject] = useState<Project | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
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);
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -59,7 +58,6 @@ export default function EditorPage() {
description: '', description: '',
content: '', content: '',
category: 'web', category: 'web',
difficulty: 'beginner',
tags: [] as string[], tags: [] as string[],
featured: false, featured: false,
published: false, published: false,
@@ -68,6 +66,44 @@ export default function EditorPage() {
image: '' image: ''
}); });
const loadProject = useCallback(async (id: string) => {
try {
console.log('Fetching projects...');
const response = await fetch('/api/projects');
if (response.ok) {
const data = await response.json();
console.log('Projects loaded:', data);
const foundProject = data.projects.find((p: Project) => p.id.toString() === id);
console.log('Found project:', foundProject);
if (foundProject) {
setProject(foundProject);
setFormData({
title: foundProject.title || '',
description: foundProject.description || '',
content: foundProject.content || '',
category: foundProject.category || 'web',
tags: foundProject.tags || [],
featured: foundProject.featured || false,
published: foundProject.published || false,
github: foundProject.github || '',
live: foundProject.live || '',
image: foundProject.image || ''
});
console.log('Form data set for project:', foundProject.title);
} else {
console.log('Project not found with ID:', id);
}
} else {
console.error('Failed to fetch projects:', response.status);
}
} catch (error) {
console.error('Error loading project:', error);
}
}, []);
// Check authentication and load project // Check authentication and load project
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -103,85 +139,92 @@ export default function EditorPage() {
}; };
init(); init();
}, [projectId]); }, [projectId, loadProject]);
const loadProject = async (id: string) => {
try {
console.log('Fetching projects...');
const response = await fetch('/api/projects');
if (response.ok) {
const data = await response.json();
console.log('Projects loaded:', data);
const foundProject = data.projects.find((p: Project) => p.id.toString() === id);
console.log('Found project:', foundProject);
if (foundProject) {
setProject(foundProject);
setFormData({
title: foundProject.title || '',
description: foundProject.description || '',
content: foundProject.content || '',
category: foundProject.category || 'web',
difficulty: foundProject.difficulty || 'beginner',
tags: foundProject.tags || [],
featured: foundProject.featured || false,
published: foundProject.published || false,
github: foundProject.github || '',
live: foundProject.live || '',
image: foundProject.image || ''
});
console.log('Form data set:', formData);
} else {
console.log('Project not found with ID:', id);
}
} else {
console.error('Failed to fetch projects:', response.status);
}
} catch (error) {
console.error('Error loading project:', error);
}
};
const handleSave = async () => { const handleSave = async () => {
try { try {
setIsSaving(true); setIsSaving(true);
// Validate required fields
if (!formData.title.trim()) {
alert('Please enter a project title');
return;
}
if (!formData.description.trim()) {
alert('Please enter a project description');
return;
}
const url = projectId ? `/api/projects/${projectId}` : '/api/projects'; const url = projectId ? `/api/projects/${projectId}` : '/api/projects';
const method = projectId ? 'PUT' : 'POST'; const method = projectId ? 'PUT' : 'POST';
console.log('Saving project:', { url, method, formData }); // Prepare data for saving - only include fields that exist in the database schema
const saveData = {
title: formData.title.trim(),
description: formData.description.trim(),
content: formData.content.trim(),
category: formData.category,
tags: formData.tags,
github: formData.github.trim() || null,
live: formData.live.trim() || null,
imageUrl: formData.image.trim() || null,
published: formData.published,
featured: formData.featured,
// Add required fields that might be missing
date: new Date().toISOString().split('T')[0] // Current date in YYYY-MM-DD format
};
console.log('Saving project:', { url, method, saveData });
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-admin-request': 'true'
}, },
body: JSON.stringify(formData) body: JSON.stringify(saveData)
}); });
if (response.ok) { if (response.ok) {
const savedProject = await response.json(); const savedProject = await response.json();
console.log('Project saved:', savedProject); console.log('Project saved successfully:', savedProject);
// Update local state with the saved project data
setProject(savedProject);
setFormData(prev => ({
...prev,
title: savedProject.title || '',
description: savedProject.description || '',
content: savedProject.content || '',
category: savedProject.category || 'web',
tags: savedProject.tags || [],
featured: savedProject.featured || false,
published: savedProject.published || false,
github: savedProject.github || '',
live: savedProject.live || '',
image: savedProject.imageUrl || ''
}));
// Show success and redirect // Show success and redirect
alert('Project saved successfully!');
setTimeout(() => { setTimeout(() => {
window.location.href = '/manage'; window.location.href = '/manage';
}, 1000); }, 1000);
} else { } else {
console.error('Error saving project:', response.status); const errorData = await response.json();
alert('Error saving project'); console.error('Error saving project:', response.status, errorData);
alert(`Error saving project: ${errorData.error || 'Unknown error'}`);
} }
} catch (error) { } catch (error) {
console.error('Error saving project:', error); console.error('Error saving project:', error);
alert('Error saving project'); alert(`Error saving project: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; };
const handleInputChange = (field: string, value: any) => { const handleInputChange = (field: string, value: string | boolean | string[]) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[field]: value [field]: value
@@ -196,6 +239,42 @@ export default function EditorPage() {
})); }));
}; };
// Simple markdown to HTML converter
const parseMarkdown = (text: string) => {
if (!text) return '';
return text
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
// Inline code
.replace(/`(.*?)`/g, '<code>$1</code>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Ensure all images have alt attributes
.replace(/<img([^>]*?)(?:\s+alt\s*=\s*["'][^"']*["'])?([^>]*?)>/g, (match, before, after) => {
if (match.includes('alt=')) return match;
return `<img${before} alt=""${after}>`;
})
// Lists
.replace(/^\* (.*$)/gim, '<li>$1</li>')
.replace(/^- (.*$)/gim, '<li>$1</li>')
.replace(/^(\d+)\. (.*$)/gim, '<li>$2</li>')
// Blockquotes
.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
// Line breaks
.replace(/\n/g, '<br>');
};
// Rich text editor functions // Rich text editor functions
const insertFormatting = (format: string) => { const insertFormatting = (format: string) => {
const content = contentRef.current; const content = contentRef.current;
@@ -263,14 +342,21 @@ export default function EditorPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen admin-gradient flex items-center justify-center"> <div className="min-h-screen animated-bg flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="glass-card p-8 rounded-2xl"
>
<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 mx-auto mb-4" className="w-12 h-12 border-3 border-blue-500 border-t-transparent rounded-full mx-auto mb-6"
/> />
<p className="text-white">Loading editor...</p> <h2 className="text-xl font-semibold gradient-text mb-2">Loading Editor</h2>
<p className="text-gray-400">Preparing your workspace...</p>
</motion.div>
</div> </div>
</div> </div>
); );
@@ -278,7 +364,7 @@ export default function EditorPage() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="min-h-screen admin-gradient flex items-center justify-center"> <div className="min-h-screen animated-bg flex items-center justify-center">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
@@ -304,42 +390,43 @@ export default function EditorPage() {
} }
return ( return (
<div className="min-h-screen admin-gradient"> <div className="min-h-screen animated-bg">
{/* Header */} {/* Header */}
<div className="admin-glass-header border-b border-white/10"> <div className="glass-card border-b border-white/10 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-16"> <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 items-center 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="flex items-center space-x-2 text-white/70 hover:text-white transition-colors" className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
<span>Back to Dashboard</span> <span className="hidden sm:inline">Back to Dashboard</span>
<span className="sm:hidden">Back</span>
</button> </button>
<div className="h-6 w-px bg-white/20" /> <div className="hidden sm:block h-6 w-px bg-white/20" />
<h1 className="text-xl font-semibold text-white"> <h1 className="text-lg sm:text-xl font-semibold gradient-text truncate max-w-xs sm:max-w-none">
{isCreating ? 'Create New Project' : `Edit: ${formData.title || 'Untitled'}`} {isCreating ? 'Create New Project' : `Edit: ${formData.title || 'Untitled'}`}
</h1> </h1>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-2 sm:space-x-3 w-full sm:w-auto">
<button <button
onClick={() => setShowPreview(!showPreview)} onClick={() => setShowPreview(!showPreview)}
className={`flex items-center space-x-2 px-4 py-2 rounded-xl transition-all ${ className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 text-sm ${
showPreview showPreview
? 'bg-purple-500 text-white' ? 'bg-blue-600 text-white shadow-lg'
: 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-gray-800/50 text-gray-300 hover:bg-gray-700/50 hover:text-white'
}`} }`}
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
<span>Preview</span> <span className="hidden sm:inline">Preview</span>
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
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 font-medium disabled:opacity-50" className="btn-primary flex items-center space-x-2 px-6 py-2 text-sm sm:text-base flex-1 sm:flex-none disabled:opacity-50"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
@@ -354,21 +441,35 @@ export default function EditorPage() {
</div> </div>
{/* Editor Content */} {/* Editor Content */}
<div className="max-w-7xl mx-auto px-6 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
<div className="grid grid-cols-1 xl:grid-cols-4 gap-8"> <div className="grid grid-cols-1 xl:grid-cols-4 gap-6 lg:gap-8">
{/* Floating particles background */}
<div className="particles">
{[...Array(20)].map((_, i) => (
<div
key={i}
className="particle"
style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 20}s`,
animationDuration: `${20 + Math.random() * 10}s`
}}
/>
))}
</div>
{/* Main Editor */} {/* Main Editor */}
<div className="xl:col-span-3 space-y-6"> <div className="xl:col-span-3 space-y-6">
{/* Project Title */} {/* Project Title */}
<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" className="glass-card p-6 rounded-2xl"
> >
<input <input
type="text" type="text"
value={formData.title} value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)} onChange={(e) => handleInputChange('title', e.target.value)}
className="w-full text-3xl font-bold bg-white/10 text-white placeholder-white/50 focus:outline-none p-4 rounded-lg border border-white/20 focus:ring-2 focus:ring-blue-500" className="w-full text-3xl font-bold form-input-enhanced focus:outline-none p-4 rounded-lg"
placeholder="Enter project title..." placeholder="Enter project title..."
/> />
</motion.div> </motion.div>
@@ -378,95 +479,95 @@ export default function EditorPage() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="admin-glass-card p-4 rounded-xl" className="glass-card p-4 rounded-2xl"
> >
<div className="flex flex-wrap items-center gap-2"> <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-3"> <div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button <button
onClick={() => insertFormatting('bold')} onClick={() => insertFormatting('bold')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Bold" title="Bold"
> >
<Bold className="w-4 h-4 text-white/70" /> <Bold className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => insertFormatting('italic')} onClick={() => insertFormatting('italic')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Italic" title="Italic"
> >
<Italic className="w-4 h-4 text-white/70" /> <Italic className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => insertFormatting('code')} onClick={() => insertFormatting('code')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Code" title="Code"
> >
<Code className="w-4 h-4 text-white/70" /> <Code className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="flex items-center space-x-1 border-r border-white/20 pr-3"> <div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button <button
onClick={() => insertFormatting('h1')} onClick={() => insertFormatting('h1')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Heading 1" title="Heading 1"
> >
<Hash className="w-4 h-4 text-white/70" /> <Hash className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => insertFormatting('h2')} onClick={() => insertFormatting('h2')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-sm" 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" title="Heading 2"
> >
H2 H2
</button> </button>
<button <button
onClick={() => insertFormatting('h3')} onClick={() => insertFormatting('h3')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-sm" 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" title="Heading 3"
> >
H3 H3
</button> </button>
</div> </div>
<div className="flex items-center space-x-1 border-r border-white/20 pr-3"> <div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
<button <button
onClick={() => insertFormatting('list')} onClick={() => insertFormatting('list')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Bullet List" title="Bullet List"
> >
<List className="w-4 h-4 text-white/70" /> <List className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => insertFormatting('orderedList')} onClick={() => insertFormatting('orderedList')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Numbered List" title="Numbered List"
> >
<ListOrdered className="w-4 h-4 text-white/70" /> <ListOrdered className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => insertFormatting('quote')} onClick={() => insertFormatting('quote')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Quote" title="Quote"
> >
<Quote className="w-4 h-4 text-white/70" /> <Quote className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<button <button
onClick={() => insertFormatting('link')} onClick={() => insertFormatting('link')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Link" title="Link"
> >
<Link className="w-4 h-4 text-white/70" /> <Link className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => insertFormatting('image')} onClick={() => insertFormatting('image')}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 rounded-lg text-gray-300"
title="Image" title="Image"
> >
<Image className="w-4 h-4 text-white/70" /> <Image className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -477,24 +578,29 @@ export default function EditorPage() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className="admin-glass-card p-6 rounded-xl" className="glass-card p-6 rounded-2xl"
> >
<h3 className="text-lg font-semibold text-white mb-4">Content</h3> <h3 className="text-lg font-semibold gradient-text mb-4">Content</h3>
<div <div
ref={contentRef} ref={contentRef}
contentEditable contentEditable
className="w-full min-h-[400px] p-6 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 leading-relaxed" 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' }} style={{ whiteSpace: 'pre-wrap' }}
onInput={(e) => { onInput={(e) => {
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
setIsTyping(true);
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
content: target.textContent || '' content: target.textContent || ''
})); }));
}} }}
onBlur={() => {
setIsTyping(false);
}}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
data-placeholder="Start writing your project content..."
> >
{formData.content || 'Start writing your project content...'} {!isTyping ? formData.content : undefined}
</div> </div>
<p className="text-xs text-white/50 mt-2"> <p className="text-xs text-white/50 mt-2">
Supports Markdown formatting. Use the toolbar above or type directly. Supports Markdown formatting. Use the toolbar above or type directly.
@@ -506,14 +612,14 @@ export default function EditorPage() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
className="admin-glass-card p-6 rounded-xl" className="glass-card p-6 rounded-2xl"
> >
<h3 className="text-lg font-semibold text-white mb-4">Description</h3> <h3 className="text-lg font-semibold gradient-text mb-4">Description</h3>
<textarea <textarea
value={formData.description} value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)} onChange={(e) => handleInputChange('description', e.target.value)}
rows={4} rows={4}
className="w-full px-4 py-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 px-4 py-3 form-input-enhanced rounded-lg focus:outline-none resize-none"
placeholder="Brief description of your project..." placeholder="Brief description of your project..."
/> />
</motion.div> </motion.div>
@@ -526,19 +632,19 @@ export default function EditorPage() {
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="admin-glass-card p-6 rounded-xl" className="glass-card p-6 rounded-2xl"
> >
<h3 className="text-lg font-semibold text-white mb-4">Settings</h3> <h3 className="text-lg font-semibold gradient-text mb-4">Settings</h3>
<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-white/70 mb-2">
Category Category
</label> </label>
<div className="custom-select">
<select <select
value={formData.category} value={formData.category}
onChange={(e) => handleInputChange('category', e.target.value)} onChange={(e) => handleInputChange('category', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="web">Web Development</option> <option value="web">Web Development</option>
<option value="mobile">Mobile Development</option> <option value="mobile">Mobile Development</option>
@@ -548,22 +654,9 @@ export default function EditorPage() {
<option value="other">Other</option> <option value="other">Other</option>
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Difficulty
</label>
<select
value={formData.difficulty}
onChange={(e) => handleInputChange('difficulty', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-2"> <label className="block text-sm font-medium text-white/70 mb-2">
Tags Tags
@@ -572,7 +665,7 @@ export default function EditorPage() {
type="text" type="text"
value={formData.tags.join(', ')} value={formData.tags.join(', ')}
onChange={(e) => handleTagsChange(e.target.value)} onChange={(e) => handleTagsChange(e.target.value)}
className="w-full px-3 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 px-3 py-2 form-input-enhanced rounded-lg focus:outline-none"
placeholder="React, TypeScript, Next.js" placeholder="React, TypeScript, Next.js"
/> />
</div> </div>
@@ -584,9 +677,9 @@ export default function EditorPage() {
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="admin-glass-card p-6 rounded-xl" className="glass-card p-6 rounded-2xl"
> >
<h3 className="text-lg font-semibold text-white mb-4">Links</h3> <h3 className="text-lg font-semibold gradient-text mb-4">Links</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -597,7 +690,7 @@ export default function EditorPage() {
type="url" type="url"
value={formData.github} value={formData.github}
onChange={(e) => handleInputChange('github', e.target.value)} onChange={(e) => handleInputChange('github', e.target.value)}
className="w-full px-3 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 px-3 py-2 form-input-enhanced rounded-lg focus:outline-none"
placeholder="https://github.com/username/repo" placeholder="https://github.com/username/repo"
/> />
</div> </div>
@@ -610,7 +703,7 @@ export default function EditorPage() {
type="url" type="url"
value={formData.live} value={formData.live}
onChange={(e) => handleInputChange('live', e.target.value)} onChange={(e) => handleInputChange('live', e.target.value)}
className="w-full px-3 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 px-3 py-2 form-input-enhanced rounded-lg focus:outline-none"
placeholder="https://example.com" placeholder="https://example.com"
/> />
</div> </div>
@@ -622,9 +715,9 @@ export default function EditorPage() {
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="admin-glass-card p-6 rounded-xl" className="glass-card p-6 rounded-2xl"
> >
<h3 className="text-lg font-semibold text-white mb-4">Publish</h3> <h3 className="text-lg font-semibold gradient-text mb-4">Publish</h3>
<div className="space-y-4"> <div className="space-y-4">
<label className="flex items-center space-x-3"> <label className="flex items-center space-x-3">
@@ -632,7 +725,7 @@ export default function EditorPage() {
type="checkbox" type="checkbox"
checked={formData.featured} checked={formData.featured}
onChange={(e) => handleInputChange('featured', e.target.checked)} onChange={(e) => handleInputChange('featured', e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white/10 border-white/20 rounded focus:ring-blue-500" className="w-4 h-4 text-blue-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-blue-500 focus:ring-2"
/> />
<span className="text-white">Featured Project</span> <span className="text-white">Featured Project</span>
</label> </label>
@@ -642,7 +735,7 @@ export default function EditorPage() {
type="checkbox" type="checkbox"
checked={formData.published} checked={formData.published}
onChange={(e) => handleInputChange('published', e.target.checked)} onChange={(e) => handleInputChange('published', e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white/10 border-white/20 rounded focus:ring-blue-500" className="w-4 h-4 text-blue-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-blue-500 focus:ring-2"
/> />
<span className="text-white">Published</span> <span className="text-white">Published</span>
</label> </label>
@@ -661,6 +754,151 @@ export default function EditorPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Preview Modal */}
<AnimatePresence>
{showPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={() => setShowPreview(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="glass-card rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold gradient-text">Project Preview</h2>
<button
onClick={() => setShowPreview(false)}
className="p-2 rounded-lg"
>
<X className="w-5 h-5 text-white/70" />
</button>
</div>
{/* Preview Content */}
<div className="space-y-6">
{/* Project Header */}
<div className="text-center">
<h1 className="text-4xl font-bold gradient-text mb-4">
{formData.title || 'Untitled Project'}
</h1>
<p className="text-xl text-gray-400 mb-6">
{formData.description || 'No description provided'}
</p>
{/* Project Meta */}
<div className="flex flex-wrap justify-center gap-4 mb-6">
<div className="flex items-center space-x-2 text-gray-300">
<Tag className="w-4 h-4" />
<span className="capitalize">{formData.category}</span>
</div>
{formData.featured && (
<div className="flex items-center space-x-2 text-blue-400">
<span className="text-sm font-semibold"> Featured</span>
</div>
)}
</div>
{/* Tags */}
{formData.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mb-6">
{formData.tags.map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700"
>
{tag}
</span>
))}
</div>
)}
{/* Links */}
{((formData.github && formData.github.trim()) || (formData.live && formData.live.trim())) && (
<div className="flex justify-center space-x-4 mb-8">
{formData.github && formData.github.trim() && (
<a
href={formData.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 px-4 py-2 bg-gray-800/50 text-gray-300 rounded-lg"
>
<Github className="w-4 h-4" />
<span>GitHub</span>
</a>
)}
{formData.live && formData.live.trim() && (
<a
href={formData.live}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 px-4 py-2 bg-blue-600/80 text-white rounded-lg"
>
<ExternalLink className="w-4 h-4" />
<span>Live Demo</span>
</a>
)}
</div>
)}
</div>
{/* Content Preview */}
{formData.content && (
<div className="border-t border-white/10 pt-6">
<h3 className="text-xl font-semibold gradient-text mb-4">Content</h3>
<div className="prose prose-invert max-w-none">
<div
className="markdown text-gray-300 leading-relaxed"
dangerouslySetInnerHTML={{ __html: parseMarkdown(formData.content) }}
/>
</div>
</div>
)}
{/* Status */}
<div className="border-t border-white/10 pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
formData.published
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}>
{formData.published ? 'Published' : 'Draft'}
</span>
{formData.featured && (
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm font-medium">
Featured
</span>
)}
</div>
<div className="text-sm text-gray-400">
Last updated: {new Date().toLocaleDateString()}
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
export default function EditorPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-white">Loading editor...</div>
</div>}>
<EditorPageContent />
</Suspense>
);
}

View File

@@ -114,6 +114,132 @@ body {
transition: all 0.2s ease !important; transition: all 0.2s ease !important;
} }
/* Admin Gradient Background */
.admin-gradient {
background:
radial-gradient(circle at 20% 80%, rgba(59, 130, 246, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.15) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(236, 72, 153, 0.08) 0%, transparent 50%),
linear-gradient(-45deg, #0a0a0a, #111111, #0d0d0d, #151515);
background-size: 400% 400%, 400% 400%, 400% 400%, 400% 400%;
animation: gradientShift 25s ease infinite;
min-height: 100vh;
}
/* Admin Glass Header */
.admin-glass-header {
background: rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(20px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.15) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3) !important;
}
/* Editor-specific styles */
.editor-content-editable:empty:before {
content: attr(data-placeholder);
color: #9ca3af;
pointer-events: none;
font-style: italic;
opacity: 0.7;
}
.editor-content-editable:focus:before {
content: none;
}
.editor-content-editable:empty {
min-height: 400px;
display: flex;
align-items: flex-start;
padding-top: 1.5rem;
}
.editor-content-editable:not(:empty) {
min-height: 400px;
}
/* Enhanced form styling */
.form-input-enhanced {
background: rgba(17, 24, 39, 0.8) !important;
border: 1px solid rgba(75, 85, 99, 0.5) !important;
color: #ffffff !important;
transition: all 0.3s ease !important;
backdrop-filter: blur(10px) !important;
}
.form-input-enhanced:focus {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2), 0 0 20px rgba(59, 130, 246, 0.1) !important;
background: rgba(17, 24, 39, 0.9) !important;
transform: translateY(-1px) !important;
}
.form-input-enhanced::placeholder {
color: #9ca3af !important;
}
/* Select styling */
select.form-input-enhanced {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
appearance: none;
cursor: pointer;
}
select.form-input-enhanced:focus {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%233b82f6' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
}
/* Custom dropdown styling */
.custom-select {
position: relative;
display: inline-block;
width: 100%;
}
.custom-select select {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 0.75rem;
background: rgba(17, 24, 39, 0.8);
border: 1px solid rgba(75, 85, 99, 0.5);
border-radius: 0.5rem;
color: #ffffff;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.75rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
}
.custom-select select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2), 0 0 20px rgba(59, 130, 246, 0.1);
background: rgba(17, 24, 39, 0.9);
transform: translateY(-1px);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%233b82f6' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
}
/* Ensure no default browser arrows show */
.custom-select select::-ms-expand {
display: none;
}
.custom-select select::-webkit-appearance {
-webkit-appearance: none;
}
/* Gradient Text */ /* Gradient Text */
.gradient-text { .gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

View File

@@ -115,7 +115,9 @@ const ProjectDetail = () => {
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
{((project.github && project.github.trim() && project.github !== "#") || (project.live && project.live.trim() && project.live !== "#")) && (
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{project.github && project.github.trim() && project.github !== "#" && (
<motion.a <motion.a
href={project.github} href={project.github}
target="_blank" target="_blank"
@@ -127,8 +129,9 @@ const ProjectDetail = () => {
<GithubIcon size={20} /> <GithubIcon size={20} />
<span>View Code</span> <span>View Code</span>
</motion.a> </motion.a>
)}
{project.live !== "#" && ( {project.live && project.live.trim() && project.live !== "#" && (
<motion.a <motion.a
href={project.live} href={project.live}
target="_blank" target="_blank"
@@ -142,6 +145,7 @@ const ProjectDetail = () => {
</motion.a> </motion.a>
)} )}
</div> </div>
)}
</motion.div> </motion.div>
{/* Project Content */} {/* Project Content */}

View File

@@ -145,8 +145,9 @@ const ProjectsPage = () => {
</div> </div>
)} )}
{((project.github && project.github.trim() && project.github !== "#") || (project.live && project.live.trim() && project.live !== "#")) && (
<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-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center space-x-4">
{project.github && ( {project.github && project.github.trim() && project.github !== "#" && (
<motion.a <motion.a
href={project.github} href={project.github}
target="_blank" target="_blank"
@@ -158,7 +159,7 @@ const ProjectsPage = () => {
<Github size={20} /> <Github size={20} />
</motion.a> </motion.a>
)} )}
{project.live && project.live !== "#" && ( {project.live && project.live.trim() && project.live !== "#" && (
<motion.a <motion.a
href={project.live} href={project.live}
target="_blank" target="_blank"
@@ -171,6 +172,7 @@ const ProjectsPage = () => {
</motion.a> </motion.a>
)} )}
</div> </div>
)}
</div> </div>
<div className="p-6"> <div className="p-6">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
BarChart3, BarChart3,
@@ -8,16 +8,12 @@ import {
Eye, Eye,
Heart, Heart,
Zap, Zap,
Users,
Clock,
Globe, Globe,
Activity, Activity,
Target, Target,
Award, Award,
RefreshCw, RefreshCw,
Calendar,
MousePointer, MousePointer,
Monitor,
RotateCcw, RotateCcw,
Trash2, Trash2,
AlertTriangle AlertTriangle
@@ -76,7 +72,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
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 fetchAnalyticsData = async () => { const fetchAnalyticsData = useCallback(async () => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
try { try {
@@ -132,7 +128,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [isAuthenticated]);
const resetAnalytics = async () => { const resetAnalytics = async () => {
if (!isAuthenticated || resetting) return; if (!isAuthenticated || resetting) return;
@@ -167,7 +163,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
if (isAuthenticated) { if (isAuthenticated) {
fetchAnalyticsData(); fetchAnalyticsData();
} }
}, [isAuthenticated, timeRange]); }, [isAuthenticated, fetchAnalyticsData]);
const StatCard = ({ title, value, icon: Icon, color, trend, trendValue, description }: { const StatCard = ({ title, value, icon: Icon, color, trend, trendValue, description }: {
title: string; title: string;
@@ -530,7 +526,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
<label className="block text-white/80 text-sm mb-2">Reset Type</label> <label className="block text-white/80 text-sm mb-2">Reset Type</label>
<select <select
value={resetType} value={resetType}
onChange={(e) => setResetType(e.target.value as any)} onChange={(e) => setResetType(e.target.value as 'all' | 'performance' | 'analytics')}
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-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-red-500"
> >
<option value="analytics">Analytics Only (views, likes, shares)</option> <option value="analytics">Analytics Only (views, likes, shares)</option>

View File

@@ -5,11 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { import {
Mail, Mail,
Search, Search,
Filter,
Reply, Reply,
Archive,
Trash2,
Clock,
User, User,
CheckCircle, CheckCircle,
Circle, Circle,
@@ -54,7 +50,7 @@ export const EmailManager: React.FC = () => {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const formattedMessages = data.contacts.map((contact: any) => ({ const formattedMessages = data.contacts.map((contact: ContactMessage) => ({
id: contact.id.toString(), id: contact.id.toString(),
name: contact.name, name: contact.name,
email: contact.email, email: contact.email,
@@ -141,6 +137,7 @@ export const EmailManager: React.FC = () => {
}); });
}; };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getPriorityColor = (priority: string) => { const getPriorityColor = (priority: string) => {
switch (priority) { switch (priority) {
case 'high': return 'text-red-400'; case 'high': return 'text-red-400';
@@ -195,7 +192,7 @@ export const EmailManager: React.FC = () => {
{['all', 'unread', 'responded'].map((filterType) => ( {['all', 'unread', 'responded'].map((filterType) => (
<button <button
key={filterType} key={filterType}
onClick={() => setFilter(filterType as any)} 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-blue-500 text-white'

View File

@@ -6,37 +6,21 @@ import {
Save, Save,
X, X,
Eye, Eye,
EyeOff,
Settings, Settings,
Link as LinkIcon,
Tag,
Calendar,
Globe, Globe,
Github, Github,
Image as ImageIcon, Image as ImageIcon,
Bold, Bold,
Italic, Italic,
List, List,
Hash,
Quote, Quote,
Code, Code,
Zap,
Type,
Columns,
PanelLeft,
PanelRight,
Monitor,
Smartphone,
Tablet,
Undo,
Redo,
AlignLeft,
AlignCenter,
AlignRight,
Link2, Link2,
ListOrdered, ListOrdered,
Underline, Underline,
Strikethrough Strikethrough,
Type,
Columns
} from 'lucide-react'; } from 'lucide-react';
interface Project { interface Project {
@@ -60,7 +44,7 @@ interface GhostEditorProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
project?: Project | null; project?: Project | null;
onSave: (projectData: any) => void; onSave: (projectData: Partial<Project>) => void;
isCreating: boolean; isCreating: boolean;
} }
@@ -251,7 +235,7 @@ export const GhostEditor: React.FC<GhostEditorProps> = ({
// Render markdown preview // Render markdown preview
const renderMarkdownPreview = (markdown: string) => { const renderMarkdownPreview = (markdown: string) => {
// Simple markdown renderer for preview // Simple markdown renderer for preview
let html = markdown const html = markdown
// Headers // Headers
.replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>') .replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>')
.replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>') .replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>')

View File

@@ -7,8 +7,6 @@ import {
Settings, Settings,
TrendingUp, TrendingUp,
Plus, Plus,
Edit,
Trash2,
Shield, Shield,
Users, Users,
Activity, Activity,
@@ -56,6 +54,7 @@ interface ModernAdminDashboardProps {
const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthenticated = true }) => { const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthenticated = true }) => {
const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'settings'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'settings'>('overview');
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [analytics, setAnalytics] = useState<Record<string, unknown> | null>(null); const [analytics, setAnalytics] = useState<Record<string, unknown> | null>(null);
@@ -540,7 +539,7 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
</div> </div>
</div> </div>
<ProjectManager isAuthenticated={isAuthenticated} projects={projects} onProjectsChange={loadProjects} /> <ProjectManager projects={projects} onProjectsChange={loadProjects} />
</div> </div>
)} )}

View File

@@ -1,29 +1,16 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
Plus, Plus,
Edit, Edit,
Trash2, Trash2,
Eye,
Search, Search,
Filter,
Grid, Grid,
List, List,
Save,
X,
Upload,
Image as ImageIcon,
Link as LinkIcon,
Globe, Globe,
Github, Github,
Calendar,
Tag,
Star,
TrendingUp,
Settings,
MoreVertical,
RefreshCw RefreshCw
} from 'lucide-react'; } from 'lucide-react';
// Editor is now a separate page at /editor // Editor is now a separate page at /editor
@@ -54,13 +41,11 @@ interface Project {
} }
interface ProjectManagerProps { interface ProjectManagerProps {
isAuthenticated: boolean;
projects: Project[]; projects: Project[];
onProjectsChange: () => void; onProjectsChange: () => void;
} }
export const ProjectManager: React.FC<ProjectManagerProps> = ({ export const ProjectManager: React.FC<ProjectManagerProps> = ({
isAuthenticated,
projects, projects,
onProjectsChange onProjectsChange
}) => { }) => {
@@ -70,7 +55,6 @@ export const ProjectManager: React.FC<ProjectManagerProps> = ({
// Editor is now a separate page - no modal state needed // 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'];
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
// Filter projects // Filter projects
const filteredProjects = projects.filter((project) => { const filteredProjects = projects.filter((project) => {

View File

@@ -8,36 +8,19 @@ import {
Eye, Eye,
EyeOff, EyeOff,
Settings, Settings,
Link as LinkIcon,
Tag,
Calendar,
Globe, Globe,
Github, Github,
Image as ImageIcon,
Bold, Bold,
Italic, Italic,
List, List,
Hash,
Quote, Quote,
Code, Code,
Zap,
Type,
Columns,
PanelLeft,
PanelRight,
Monitor,
Smartphone,
Tablet,
Undo,
Redo,
AlignLeft,
AlignCenter,
AlignRight,
Link2, Link2,
ListOrdered, ListOrdered,
Underline, Underline,
Strikethrough, Strikethrough,
GripVertical GripVertical,
Image as ImageIcon
} from 'lucide-react'; } from 'lucide-react';
interface Project { interface Project {
@@ -59,7 +42,7 @@ interface Project {
interface ResizableGhostEditorProps { interface ResizableGhostEditorProps {
project?: Project | null; project?: Project | null;
onSave: (projectData: any) => void; onSave: (projectData: Partial<Project>) => void;
onClose: () => void; onClose: () => void;
isCreating: boolean; isCreating: boolean;
} }
@@ -277,7 +260,7 @@ export const ResizableGhostEditor: React.FC<ResizableGhostEditorProps> = ({
// Enhanced markdown renderer with proper white text // Enhanced markdown renderer with proper white text
const renderMarkdownPreview = (markdown: string) => { const renderMarkdownPreview = (markdown: string) => {
let html = markdown const html = markdown
// Headers - WHITE TEXT // Headers - WHITE TEXT
.replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>') .replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>')
.replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>') .replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>')

View File

@@ -2,12 +2,30 @@ import { cache } from './redis';
// API Response caching // API Response caching
export const apiCache = { export const apiCache = {
async getProjects() { // Generate cache key based on query parameters
return await cache.get('api:projects'); generateProjectsKey(params: Record<string, string | null> = {}) {
const { page = '1', limit = '50', category, featured, published, difficulty, search } = params;
const keyParts = ['api:projects'];
if (page !== '1') keyParts.push(`page:${page}`);
if (limit !== '50') keyParts.push(`limit:${limit}`);
if (category) keyParts.push(`cat:${category}`);
if (featured !== null) keyParts.push(`feat:${featured}`);
if (published !== null) keyParts.push(`pub:${published}`);
if (difficulty) keyParts.push(`diff:${difficulty}`);
if (search) keyParts.push(`search:${search}`);
return keyParts.join(':');
}, },
async setProjects(projects: unknown, ttlSeconds = 300) { async getProjects(params: Record<string, string | null> = {}) {
return await cache.set('api:projects', projects, ttlSeconds); const key = this.generateProjectsKey(params);
return await cache.get(key);
},
async setProjects(params: Record<string, string | null> = {}, projects: unknown, ttlSeconds = 300) {
const key = this.generateProjectsKey(params);
return await cache.set(key, projects, ttlSeconds);
}, },
async getProject(id: number) { async getProject(id: number) {
@@ -20,11 +38,28 @@ export const apiCache = {
async invalidateProject(id: number) { async invalidateProject(id: number) {
await cache.del(`api:project:${id}`); await cache.del(`api:project:${id}`);
await cache.del('api:projects'); // Invalidate all project list caches
await this.invalidateAllProjectLists();
},
async invalidateAllProjectLists() {
// Clear all project list caches by pattern
// This is a simplified approach - in production you'd use Redis SCAN
const commonKeys = [
'api:projects',
'api:projects:pub:true',
'api:projects:feat:true:pub:true:limit:6',
'api:projects:page:1:limit:50',
'api:projects:pub:true:page:1:limit:50'
];
for (const key of commonKeys) {
await cache.del(key);
}
}, },
async invalidateAll() { async invalidateAll() {
await cache.del('api:projects'); await this.invalidateAllProjectLists();
// Clear all project caches // Clear all project caches
const keys = await this.getAllProjectKeys(); const keys = await this.getAllProjectKeys();
for (const key of keys) { for (const key of keys) {