🎨 Complete Portfolio Redesign: Modern Dark Theme + Admin Dashboard + Enhanced Markdown Editor
✨ New Features: - Complete dark theme redesign with glassmorphism effects - Responsive admin dashboard with collapsible projects list - Enhanced markdown editor with live preview - Project image upload functionality - Improved project management (create, edit, delete, publish/unpublish) - Slug-based project URLs - Legal pages (Impressum, Privacy Policy) - Modern animations with Framer Motion 🔧 Improvements: - Fixed hydration errors with mounted state - Enhanced UI/UX with better spacing and proportions - Improved markdown rendering with custom components - Better project image placeholders with initials - Conditional rendering for GitHub/Live Demo links - Enhanced toolbar with categorized quick actions - Responsive grid layout for admin dashboard 📱 Technical: - Next.js 15 + TypeScript + Tailwind CSS - Local storage for project persistence - Optimized performance and responsive design
This commit is contained in:
258
README.md
258
README.md
@@ -1,248 +1,30 @@
|
||||
# Dennis Konkol's Portfolio Website
|
||||
# Dennis Konkol Portfolio - Modern Dark Theme
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [
|
||||
`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Denshooter/my_portfolio.git
|
||||
cd my_portfolio
|
||||
```
|
||||
|
||||
Then, install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
# or
|
||||
pnpm install
|
||||
# or
|
||||
bun install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
To run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
### Build
|
||||
|
||||
To build the application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
yarn build
|
||||
# or
|
||||
pnpm build
|
||||
# or
|
||||
bun build
|
||||
```
|
||||
|
||||
### Start
|
||||
|
||||
To start the application in production mode:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
# or
|
||||
yarn start
|
||||
# or
|
||||
pnpm start
|
||||
# or
|
||||
bun start
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
To run the linter:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
# or
|
||||
yarn lint
|
||||
# or
|
||||
pnpm lint
|
||||
# or
|
||||
bun lint
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
my_portfolio/
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── main.yml
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── email/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── fetchAllProjects/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── fetchImage/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── fetchProject/
|
||||
│ │ │ └── route.tsx
|
||||
│ │
|
||||
|
||||
i mean to give more examples how it was built what it features and how it works not how to run it, but more explanations about the project and how its deployed etc.
|
||||
|
||||
Got it! Here's an updated README.md that provides more detailed explanations about the project, its features, how it was built, and how it is deployed:
|
||||
|
||||
```md
|
||||
# Dennis Konkol's Portfolio
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). It showcases my projects, provides a way to contact me, and includes various other features.
|
||||
Ein modernes, responsives Portfolio mit dunklem Design, coolen Animationen und einem integrierten Markdown-Editor.
|
||||
|
||||
## Features
|
||||
|
||||
- **Responsive Design**: The portfolio is fully responsive and works on all devices.
|
||||
- **Project Showcase**: Displays a list of projects fetched from a Ghost CMS.
|
||||
- **Contact Form**: Allows visitors to send me messages via email.
|
||||
- **SEO Optimized**: Includes metadata and Open Graph tags for better SEO.
|
||||
- **Dynamic Sitemap**: Automatically generates a sitemap for better search engine indexing.
|
||||
- **Cookie Consent**: Includes a cookie consent banner to comply with GDPR.
|
||||
- **Zero-Downtime Deployment**: Uses Docker and GitHub Actions for seamless deployments to a Raspberry Pi.
|
||||
- **Dunkles Theme** mit Glassmorphism-Effekten
|
||||
- **Responsive Design** für alle Geräte
|
||||
- **Smooth Animationen** mit Framer Motion
|
||||
- **Markdown-Editor** für Projekte
|
||||
- **Admin Dashboard** für Content-Management
|
||||
|
||||
## Project Structure
|
||||
## Technologien
|
||||
|
||||
```plaintext
|
||||
my_portfolio/
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── main.yml
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── email/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── fetchAllProjects/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── fetchImage/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── fetchProject/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── og/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── projects/
|
||||
│ │ │ └── route.tsx
|
||||
│ │ ├── sitemap/
|
||||
│ │ │ └── route.tsx
|
||||
│ ├── components/
|
||||
│ │ ├── ClientCookieConsentBanner.tsx
|
||||
│ │ ├── Contact.tsx
|
||||
│ │ ├── CookieConsentBanner.tsx
|
||||
│ │ ├── Footer.tsx
|
||||
│ │ ├── Footer_Back.tsx
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Hero.tsx
|
||||
│ │ ├── Projects.tsx
|
||||
│ ├── styles/
|
||||
│ │ └── ghostContent.css
|
||||
│ ├── globals.css
|
||||
│ ├── layout.tsx
|
||||
│ ├── metadata.tsx
|
||||
│ ├── not-found.tsx
|
||||
│ ├── page.tsx
|
||||
│ ├── privacy-policy/
|
||||
│ │ └── page.tsx
|
||||
│ ├── legal-notice/
|
||||
│ │ └── page.tsx
|
||||
│ ├── projects/
|
||||
│ │ └── [slug]/
|
||||
│ │ └── page.tsx
|
||||
│ ├── sitemap.xml/
|
||||
│ │ └── route.tsx
|
||||
│ ├── utils/
|
||||
│ │ └── send-email.tsx
|
||||
├── public/
|
||||
│ ├── icons/
|
||||
│ │ ├── github.svg
|
||||
│ │ ├── linkedin.svg
|
||||
│ ├── images/
|
||||
│ ├── robots.txt
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
├── next.config.ts
|
||||
├── package.json
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
└── eslint.config.mjs
|
||||
```
|
||||
- Next.js 15 mit App Router
|
||||
- TypeScript für Type Safety
|
||||
- Tailwind CSS für Styling
|
||||
- Framer Motion für Animationen
|
||||
- React Markdown für Content
|
||||
|
||||
## How It Works
|
||||
## Installation
|
||||
|
||||
### Project Showcase
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
Projects are fetched from a Ghost CMS using the Ghost Content API. The API routes in the `app/api` directory handle
|
||||
fetching all projects, fetching a single project by slug, and fetching images.
|
||||
## Verwendung
|
||||
|
||||
### Contact Form
|
||||
|
||||
The contact form allows visitors to send me messages via email. It uses the `nodemailer` package to send emails through
|
||||
an SMTP server. The API route `app/api/email/route.tsx` handles the email sending logic.
|
||||
|
||||
### SEO and Open Graph
|
||||
|
||||
The project includes metadata and Open Graph tags to improve SEO. The `app/metadata.tsx` file defines the metadata for
|
||||
the site. The `app/api/og/route.tsx` file generates dynamic Open Graph images.
|
||||
|
||||
### Dynamic Sitemap
|
||||
|
||||
A dynamic sitemap is generated to help search engines index the site. The `app/api/sitemap/route.tsx` file generates the
|
||||
sitemap, and the `app/sitemap.xml/route.tsx` file serves it.
|
||||
|
||||
### Cookie Consent
|
||||
|
||||
A cookie consent banner is included to comply with GDPR. The `app/components/CookieConsentBanner.tsx` and
|
||||
`app/components/ClientCookieConsentBanner.tsx` components handle the display and logic of the cookie consent banner.
|
||||
|
||||
### Zero-Downtime Deployment
|
||||
|
||||
The project uses Docker and GitHub Actions for zero-downtime deployments to a Raspberry Pi. The
|
||||
`.github/workflows/main.yml` file defines the GitHub Actions workflow for deploying the project. The `Dockerfile`
|
||||
defines the Docker image for the project.
|
||||
|
||||
## Deployment
|
||||
|
||||
The project is deployed using Docker and GitHub Actions. The GitHub Actions workflow is defined in
|
||||
`.github/workflows/main.yml`. It builds the Docker image and deploys it to a Raspberry Pi with zero downtime.
|
||||
|
||||
### Steps to Deploy
|
||||
|
||||
1. **Set Up Raspberry Pi**: Ensure Docker is installed on your Raspberry Pi.
|
||||
2. **Configure GitHub Secrets**: Add the necessary secrets (e.g., `GHOST_API_KEY`, `MY_EMAIL`, `MY_PASSWORD`) to your
|
||||
GitHub repository.
|
||||
3. **Push to GitHub**: Push your changes to the `production`, `dev`, or `preview` branches to trigger the deployment
|
||||
workflow.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions
|
||||
are welcome!
|
||||
|
||||
## Author
|
||||
|
||||
- **Dennis Konkol** - [GitHub](https://github.com/Denshooter) | [LinkedIn](https://linkedin.com/in/dkonkol)
|
||||
- `/` - Homepage
|
||||
- `/projects` - Alle Projekte
|
||||
- `/admin` - Admin Dashboard mit Markdown-Editor
|
||||
|
||||
858
app/admin/page.tsx
Normal file
858
app/admin/page.tsx
Normal file
@@ -0,0 +1,858 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Save,
|
||||
Eye,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
Link as LinkIcon,
|
||||
Image as ImageIcon,
|
||||
Code,
|
||||
Quote,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Palette,
|
||||
Smile,
|
||||
FileText,
|
||||
Download,
|
||||
Upload as UploadIcon,
|
||||
Settings,
|
||||
Smartphone
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
category: string;
|
||||
date: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
published: boolean;
|
||||
imageUrl?: string;
|
||||
metaDescription?: string;
|
||||
keywords?: string;
|
||||
ogImage?: string;
|
||||
schema?: any;
|
||||
}
|
||||
|
||||
const AdminPage = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
// Load projects from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedProjects = localStorage.getItem('portfolio-projects');
|
||||
if (savedProjects) {
|
||||
setProjects(JSON.parse(savedProjects));
|
||||
} else {
|
||||
// Default projects if none exist
|
||||
const defaultProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Portfolio Website",
|
||||
description: "A modern, responsive portfolio website built with Next.js, TypeScript, and Tailwind CSS.",
|
||||
content: "# Portfolio Website\n\nThis is my personal portfolio website built with modern web technologies. The site features a dark theme with glassmorphism effects and smooth animations.\n\n## Features\n\n- **Responsive Design**: Works perfectly on all devices\n- **Dark Theme**: Modern dark mode with glassmorphism effects\n- **Animations**: Smooth animations powered by Framer Motion\n- **Markdown Support**: Projects are written in Markdown for easy editing\n- **Performance**: Optimized for speed and SEO\n\n## Technologies Used\n\n- Next.js 15\n- TypeScript\n- Tailwind CSS\n- Framer Motion\n- React Markdown\n\n## Development Process\n\nThe website was designed with a focus on user experience and performance. I used modern CSS techniques like CSS Grid, Flexbox, and custom properties to create a responsive layout.\n\n## Future Improvements\n\n- Add blog functionality\n- Implement project filtering\n- Add more interactive elements\n- Optimize for Core Web Vitals\n\n## Links\n\n- [Live Demo](https://dki.one)\n- [GitHub Repository](https://github.com/Denshooter/portfolio)",
|
||||
tags: ["Next.js", "TypeScript", "Tailwind CSS", "Framer Motion"],
|
||||
featured: true,
|
||||
category: "Web Development",
|
||||
date: "2024"
|
||||
}
|
||||
];
|
||||
setProjects(defaultProjects);
|
||||
localStorage.setItem('portfolio-projects', JSON.stringify(defaultProjects));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
|
||||
const [isPreview, setIsPreview] = useState(false);
|
||||
const [isProjectsCollapsed, setIsProjectsCollapsed] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
content: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
featured: false,
|
||||
github: '',
|
||||
live: '',
|
||||
published: true,
|
||||
imageUrl: ''
|
||||
});
|
||||
|
||||
const [markdownContent, setMarkdownContent] = useState('');
|
||||
|
||||
const categories = [
|
||||
"Web Development",
|
||||
"Full-Stack",
|
||||
"Web Application",
|
||||
"Mobile App",
|
||||
"Desktop App",
|
||||
"API Development",
|
||||
"Database Design",
|
||||
"DevOps",
|
||||
"UI/UX Design",
|
||||
"Game Development",
|
||||
"Machine Learning",
|
||||
"Data Science",
|
||||
"Blockchain",
|
||||
"IoT",
|
||||
"Cybersecurity"
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.title || !formData.description || !markdownContent || !formData.category) {
|
||||
alert('Please fill in all required fields!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (selectedProject) {
|
||||
// Update existing project
|
||||
const updatedProjects = projects.map(p =>
|
||||
p.id === selectedProject.id
|
||||
? {
|
||||
...p,
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
content: markdownContent,
|
||||
tags: formData.tags ? formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag) : [],
|
||||
category: formData.category,
|
||||
featured: formData.featured,
|
||||
github: formData.github || undefined,
|
||||
live: formData.live || undefined,
|
||||
published: formData.published,
|
||||
imageUrl: formData.imageUrl || undefined
|
||||
}
|
||||
: p
|
||||
);
|
||||
setProjects(updatedProjects);
|
||||
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
|
||||
console.log('Project updated successfully:', selectedProject.id);
|
||||
} else {
|
||||
// Create new project
|
||||
const newProject: Project = {
|
||||
id: Math.floor(Math.random() * 1000000),
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
content: markdownContent,
|
||||
tags: formData.tags ? formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag) : [],
|
||||
category: formData.category,
|
||||
featured: formData.featured,
|
||||
github: formData.github || undefined,
|
||||
live: formData.live || undefined,
|
||||
published: formData.published,
|
||||
imageUrl: formData.imageUrl || undefined,
|
||||
date: new Date().getFullYear().toString()
|
||||
};
|
||||
const updatedProjects = [...projects, newProject];
|
||||
setProjects(updatedProjects);
|
||||
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
|
||||
console.log('New project created successfully:', newProject.id);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
alert('Project saved successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error saving project:', error);
|
||||
alert('Error saving project. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (project: Project) => {
|
||||
console.log('Editing project:', project);
|
||||
setSelectedProject(project);
|
||||
setFormData({
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
content: project.content,
|
||||
tags: project.tags.join(', '),
|
||||
category: project.category,
|
||||
featured: project.featured,
|
||||
github: project.github || '',
|
||||
live: project.live || '',
|
||||
published: project.published !== undefined ? project.published : true,
|
||||
imageUrl: project.imageUrl || ''
|
||||
});
|
||||
setMarkdownContent(project.content);
|
||||
setIsPreview(false);
|
||||
};
|
||||
|
||||
const handleDelete = (projectId: number) => {
|
||||
if (confirm('Are you sure you want to delete this project?')) {
|
||||
const updatedProjects = projects.filter(p => p.id !== projectId);
|
||||
setProjects(updatedProjects);
|
||||
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
console.log('Resetting form');
|
||||
setSelectedProject(null);
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
content: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
featured: false,
|
||||
github: '',
|
||||
live: '',
|
||||
published: true,
|
||||
imageUrl: ''
|
||||
});
|
||||
setMarkdownContent('');
|
||||
setIsPreview(false);
|
||||
};
|
||||
|
||||
const insertMarkdown = (type: string) => {
|
||||
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
|
||||
let insertion = '';
|
||||
let cursorOffset = 0;
|
||||
|
||||
switch (type) {
|
||||
case 'h1':
|
||||
insertion = `# ${text.substring(start, end) || 'Heading'}`;
|
||||
cursorOffset = 2;
|
||||
break;
|
||||
case 'h2':
|
||||
insertion = `## ${text.substring(start, end) || 'Heading'}`;
|
||||
cursorOffset = 3;
|
||||
break;
|
||||
case 'bold':
|
||||
insertion = `**${text.substring(start, end) || 'bold text'}**`;
|
||||
cursorOffset = 2;
|
||||
break;
|
||||
case 'italic':
|
||||
insertion = `*${text.substring(start, end) || 'italic text'}*`;
|
||||
cursorOffset = 1;
|
||||
break;
|
||||
case 'list':
|
||||
insertion = `- ${text.substring(start, end) || 'list item'}`;
|
||||
cursorOffset = 2;
|
||||
break;
|
||||
case 'link':
|
||||
insertion = `[${text.substring(start, end) || 'link text'}](url)`;
|
||||
cursorOffset = 3;
|
||||
break;
|
||||
case 'image':
|
||||
insertion = ``;
|
||||
cursorOffset = 9;
|
||||
break;
|
||||
case 'code':
|
||||
insertion = `\`${text.substring(start, end) || 'code'}\``;
|
||||
cursorOffset = 1;
|
||||
break;
|
||||
case 'quote':
|
||||
insertion = `> ${text.substring(start, end) || 'quote text'}`;
|
||||
cursorOffset = 2;
|
||||
break;
|
||||
case 'table':
|
||||
insertion = `| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |`;
|
||||
cursorOffset = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
const newText = text.substring(0, start) + insertion + text.substring(end);
|
||||
setMarkdownContent(newText);
|
||||
|
||||
// Set cursor position and select the placeholder text for easy editing
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
if (type === 'h1' || type === 'h2') {
|
||||
// For headings, select the placeholder text so user can type directly
|
||||
const placeholderStart = start + (type === 'h1' ? 2 : 3);
|
||||
const placeholderEnd = start + insertion.length;
|
||||
textarea.setSelectionRange(placeholderStart, placeholderEnd);
|
||||
} else {
|
||||
// For other elements, position cursor appropriately
|
||||
textarea.setSelectionRange(start + insertion.length - cursorOffset, start + insertion.length - cursorOffset);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleProjectImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (file) {
|
||||
// Simulate image upload - in production you'd upload to a real service
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
setFormData(prev => ({ ...prev, imageUrl }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (file) {
|
||||
// Create a more descriptive image URL for better organization
|
||||
const imageName = file.name.replace(/\.[^/.]+$/, ""); // Remove file extension
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
|
||||
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Insert image with better alt text and a newline for spacing
|
||||
const insertion = `\n\n`;
|
||||
|
||||
const newText = text.substring(0, start) + insertion + text.substring(start);
|
||||
setMarkdownContent(newText);
|
||||
|
||||
// Focus back to textarea and position cursor after the image
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = start + insertion.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen animated-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 py-20">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Home</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6 gradient-text">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400 max-w-3xl">
|
||||
Manage your projects with the built-in Markdown editor. Create, edit, and preview your content easily.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Projects Toggle Button - Always Visible */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<motion.button
|
||||
onClick={() => setIsProjectsCollapsed(!isProjectsCollapsed)}
|
||||
className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-gray-700 to-gray-800 hover:from-gray-600 hover:to-gray-700 rounded-xl text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
||||
title={isProjectsCollapsed ? "Show Projects" : "Hide Projects"}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{isProjectsCollapsed ? (
|
||||
<>
|
||||
<ChevronRight size={20} />
|
||||
<span>Show Projects</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={20} />
|
||||
<span>Hide Projects</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-8 ${isProjectsCollapsed ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-3'}`}>
|
||||
{/* Projects List */}
|
||||
<div className={`${isProjectsCollapsed ? 'hidden' : 'lg:col-span-1'}`}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Projects</h2>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="p-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-all ${
|
||||
selectedProject?.id === project.id
|
||||
? 'bg-blue-600/20 border border-blue-500/50'
|
||||
: 'bg-gray-800/30 hover:bg-gray-700/30'
|
||||
}`}
|
||||
onClick={() => handleEdit(project)}
|
||||
>
|
||||
<h3 className="font-medium text-white mb-1">{project.title}</h3>
|
||||
<p className="text-sm text-gray-400">{project.description}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">{project.category}</span>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(project);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(project.id);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className={`${isProjectsCollapsed ? 'lg:col-span-1' : 'lg:col-span-2'}`}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{selectedProject ? 'Edit Project' : 'New Project'}
|
||||
</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setIsPreview(!isPreview)}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
isPreview
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
title="Toggle Preview"
|
||||
>
|
||||
<Eye size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||
title="Save Project"
|
||||
>
|
||||
<Save size={20} />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
{selectedProject && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedProject(null);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
||||
title="Cancel Edit"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isPreview ? (
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Project title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({...formData, category: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select category</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
GitHub URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.github || ''}
|
||||
onChange={(e) => setFormData({...formData, github: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="https://github.com/username/repo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Live Demo URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.live || ''}
|
||||
onChange={(e) => setFormData({...formData, live: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="https://demo.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Image */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Project Image (optional)
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-gray-700 to-gray-800 rounded-xl border-2 border-dashed border-gray-600 flex items-center justify-center overflow-hidden">
|
||||
{formData.imageUrl ? (
|
||||
<img
|
||||
src={formData.imageUrl}
|
||||
alt="Project preview"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{formData.title ? formData.title.split(' ').map(word => word[0]).join('').toUpperCase() : 'P'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleProjectImageUpload}
|
||||
className="hidden"
|
||||
id="project-image-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="project-image-upload"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white cursor-pointer transition-colors"
|
||||
>
|
||||
<Upload size={16} className="mr-2" />
|
||||
Choose Image
|
||||
</label>
|
||||
<p className="text-sm text-gray-400 mt-1">Upload a project image or use auto-generated initials</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
placeholder="Brief project description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Tags (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({...formData, tags: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Next.js, TypeScript, Tailwind CSS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.featured}
|
||||
onChange={(e) => setFormData({...formData, featured: e.target.checked})}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-800 border-gray-700 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Featured Project</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.published}
|
||||
onChange={(e) => setFormData({...formData, published: e.target.checked})}
|
||||
className="w-4 h-4 text-green-600 bg-gray-800 border-gray-700 rounded focus:ring-green-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Published</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Markdown Editor with Live Preview */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Content (Markdown)
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Editor */}
|
||||
<div className="space-y-4">
|
||||
{/* Image Upload - Moved to top */}
|
||||
<div className="bg-gradient-to-r from-gray-800/30 to-gray-700/30 p-4 rounded-xl border border-gray-600/30 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-300">📁 Image Upload</span>
|
||||
<span className="text-xs text-gray-500">Add images to your content</span>
|
||||
</div>
|
||||
<label className="flex items-center justify-center space-x-3 p-4 bg-gray-700/50 hover:bg-gray-600/50 rounded-lg cursor-pointer transition-all duration-200 hover:scale-105 border-2 border-dashed border-gray-600/50 hover:border-blue-500/50">
|
||||
<Upload size={20} className="text-gray-400" />
|
||||
<span className="text-gray-300 font-medium">Upload Images</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 text-center mt-2">
|
||||
Drag & drop images or click to browse • Images will be inserted at cursor position
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Toolbar */}
|
||||
<div className="bg-gradient-to-r from-gray-800/30 to-gray-700/30 p-4 rounded-xl border border-gray-600/30">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-gray-300">Quick Actions</span>
|
||||
<span className="text-xs text-gray-500">Click to insert</span>
|
||||
</div>
|
||||
|
||||
{/* Text Formatting */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-gray-500 mb-2 uppercase tracking-wide">Text Formatting</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<button
|
||||
onClick={() => insertMarkdown('h1')}
|
||||
className="p-3 bg-gradient-to-br from-blue-600/50 to-blue-700/50 hover:from-blue-500/60 hover:to-blue-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-blue-500/50 shadow-lg"
|
||||
title="Heading 1"
|
||||
>
|
||||
<span className="text-sm font-bold">H1</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('h2')}
|
||||
className="p-3 bg-gradient-to-br from-purple-600/50 to-purple-700/50 hover:from-purple-500/60 hover:to-purple-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-purple-500/50 shadow-lg"
|
||||
title="Heading 2"
|
||||
>
|
||||
<span className="text-sm font-bold">H2</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('bold')}
|
||||
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('italic')}
|
||||
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('code')}
|
||||
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
||||
title="Inline Code"
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('quote')}
|
||||
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
||||
title="Quote"
|
||||
>
|
||||
<Quote size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Elements */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-gray-500 mb-2 uppercase tracking-wide">Content Elements</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
onClick={() => insertMarkdown('list')}
|
||||
className="p-3 bg-gradient-to-br from-green-600/50 to-green-700/50 hover:from-green-500/60 hover:to-green-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-green-500/50 shadow-lg"
|
||||
title="List Item"
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('link')}
|
||||
className="p-3 bg-gradient-to-br from-blue-600/50 to-blue-700/50 hover:from-blue-500/60 hover:to-blue-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-blue-500/50 shadow-lg"
|
||||
title="Link"
|
||||
>
|
||||
<LinkIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('image')}
|
||||
className="p-3 bg-gradient-to-br from-purple-600/50 to-purple-700/50 hover:from-purple-500/60 hover:to-purple-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-purple-500/50 shadow-lg"
|
||||
title="Image"
|
||||
>
|
||||
<ImageIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertMarkdown('table')}
|
||||
className="p-3 bg-gradient-to-br from-orange-600/50 to-orange-700/50 hover:from-orange-500/60 hover:to-orange-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-orange-500/50 shadow-lg"
|
||||
title="Table"
|
||||
>
|
||||
<span className="text-sm font-bold">📊</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Textarea */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id="markdown-editor"
|
||||
value={markdownContent}
|
||||
onChange={(e) => setMarkdownContent(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full px-6 py-4 bg-gray-800/50 border border-gray-600/50 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 resize-none font-mono text-sm leading-relaxed shadow-lg"
|
||||
placeholder="✨ Write your project content in Markdown... # Start with a heading ## Add subheadings - Create lists - Add **bold** and *italic* text - Include [links](url) and  - Use `code` and code blocks"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 text-xs text-gray-500 font-mono">
|
||||
{markdownContent.length} chars
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Enhanced Live Preview */}
|
||||
<div className="space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-300">Live Preview</div>
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Real-time rendering</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-gradient-to-br from-gray-800/40 to-gray-700/40 rounded-xl border border-gray-600/50 shadow-lg min-h-[32rem]">
|
||||
<div className="markdown prose prose-invert max-w-none text-white">
|
||||
{markdownContent ? (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({children}) => <h1 className="text-3xl font-bold text-white mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">{children}</h1>,
|
||||
h2: ({children}) => <h2 className="text-2xl font-semibold text-white mb-3 border-l-4 border-blue-500 pl-3">{children}</h2>,
|
||||
h3: ({children}) => <h3 className="text-xl font-semibold text-white mb-2">{children}</h3>,
|
||||
p: ({children}) => <p className="text-gray-300 mb-3 leading-relaxed">{children}</p>,
|
||||
ul: ({children}) => <ul className="list-disc list-inside text-gray-300 mb-3 space-y-1 marker:text-blue-400">{children}</ul>,
|
||||
ol: ({children}) => <ol className="list-decimal list-inside text-gray-300 mb-3 space-y-1 marker:text-purple-400">{children}</ol>,
|
||||
li: ({children}) => <li className="text-gray-300">{children}</li>,
|
||||
a: ({href, children}) => (
|
||||
<a href={href} className="text-blue-400 hover:text-blue-300 underline transition-colors decoration-2 underline-offset-2" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({children}) => <code className="bg-gray-700/80 text-blue-400 px-2 py-1 rounded-md text-sm font-mono border border-gray-600/50">{children}</code>,
|
||||
pre: ({children}) => <pre className="bg-gray-800/80 p-4 rounded-lg overflow-x-auto mb-3 border border-gray-600/50 shadow-inner">{children}</pre>,
|
||||
blockquote: ({children}) => <blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-300 mb-3 bg-blue-500/10 py-2 rounded-r-lg">{children}</blockquote>,
|
||||
strong: ({children}) => <strong className="font-semibold text-white">{children}</strong>,
|
||||
em: ({children}) => <em className="italic text-gray-300">{children}</em>
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-20">
|
||||
<div className="text-6xl mb-4">✨</div>
|
||||
<p className="text-lg font-medium">Start writing to see the preview</p>
|
||||
<p className="text-sm">Your Markdown will appear here in real-time</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview */
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="markdown" dangerouslySetInnerHTML={{ __html: markdownContent }} />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
@@ -1,207 +1,260 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { sendEmail } from "@/app/utils/send-email";
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
|
||||
export type ContactFormData = {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
};
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Mail, Phone, MapPin, Send, Github, Linkedin, Twitter } from 'lucide-react';
|
||||
|
||||
export default function Contact() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [banner, setBanner] = useState<{
|
||||
show: boolean;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}>({
|
||||
show: false,
|
||||
message: "",
|
||||
type: "success",
|
||||
});
|
||||
// Record the time when the form is rendered
|
||||
const [formLoadedTimestamp, setFormLoadedTimestamp] = useState<number>(Date.now());
|
||||
const Contact = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormLoadedTimestamp(Date.now());
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 350);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
// Simulate form submission
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false);
|
||||
alert('Thank you for your message! I will get back to you soon.');
|
||||
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Honeypot check
|
||||
const honeypot = formData.get("hp-field");
|
||||
if (honeypot) {
|
||||
setBanner({
|
||||
show: true,
|
||||
message: "Bot detected",
|
||||
type: "error",
|
||||
});
|
||||
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
|
||||
return;
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
title: 'Email',
|
||||
value: 'contact@dki.one',
|
||||
href: 'mailto:contact@dki.one'
|
||||
},
|
||||
{
|
||||
icon: Phone,
|
||||
title: 'Phone',
|
||||
value: '+49 123 456 789',
|
||||
href: 'tel:+49123456789'
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Location',
|
||||
value: 'Osnabrück, Germany',
|
||||
href: '#'
|
||||
}
|
||||
];
|
||||
|
||||
// Time-based anti-bot check
|
||||
const timestampStr = formData.get("timestamp") as string;
|
||||
const timestamp = parseInt(timestampStr, 10);
|
||||
if (Date.now() - timestamp < 3000) {
|
||||
setBanner({
|
||||
show: true,
|
||||
message: "Please take your time filling out the form.",
|
||||
type: "error",
|
||||
});
|
||||
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
|
||||
return;
|
||||
}
|
||||
const socialLinks = [
|
||||
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||
{ icon: Twitter, href: 'https://twitter.com/dkonkol', label: 'Twitter' }
|
||||
];
|
||||
|
||||
const data: ContactFormData = {
|
||||
name: formData.get("name") as string,
|
||||
email: formData.get("email") as string,
|
||||
message: formData.get("message") as string,
|
||||
};
|
||||
|
||||
const jsonData = JSON.stringify(data);
|
||||
|
||||
const submitButton = form.querySelector("button[type='submit']");
|
||||
if (submitButton) {
|
||||
submitButton.setAttribute("disabled", "true");
|
||||
submitButton.textContent = "Sending...";
|
||||
|
||||
const response = await sendEmail(jsonData);
|
||||
if (response.success) {
|
||||
form.reset();
|
||||
submitButton.textContent = "Sent!";
|
||||
setTimeout(() => {
|
||||
submitButton.removeAttribute("disabled");
|
||||
submitButton.textContent = "Send Message";
|
||||
}, 2000);
|
||||
}
|
||||
setBanner({
|
||||
show: true,
|
||||
message: response.message,
|
||||
type: response.success ? "success" : "error",
|
||||
});
|
||||
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
|
||||
}
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="contact"
|
||||
className={`p-10 ${isVisible ? "animate-fade-in" : "opacity-0"}`}
|
||||
>
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-8">
|
||||
Get in Touch
|
||||
</h2>
|
||||
<div className="bg-white/30 p-8 rounded-3xl shadow-xl max-w-lg mx-auto">
|
||||
{banner.show && (
|
||||
<div
|
||||
className={`mb-4 text-center rounded-full py-2 px-4 text-white ${
|
||||
banner.type === "success" ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
<section id="contact" className="py-20 px-4 relative">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||
Get In Touch
|
||||
</h2>
|
||||
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||
Have a project in mind or want to collaborate? I would love to hear from you!
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Information */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{banner.message}
|
||||
</div>
|
||||
)}
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
{/* Honeypot field */}
|
||||
<input
|
||||
type="text"
|
||||
name="hp-field"
|
||||
style={{ display: "none" }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* Hidden timestamp field */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="timestamp"
|
||||
value={formLoadedTimestamp.toString()}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-6">
|
||||
Let's Connect
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
I'm always open to discussing new opportunities, interesting projects,
|
||||
or just having a chat about technology and innovation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Your Name"
|
||||
required
|
||||
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* Contact Details */}
|
||||
<div className="space-y-4">
|
||||
{contactInfo.map((info, index) => (
|
||||
<motion.a
|
||||
key={info.title}
|
||||
href={info.href}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
whileHover={{ x: 5 }}
|
||||
className="flex items-center space-x-4 p-4 rounded-lg glass-card hover:bg-gray-800/30 transition-colors group"
|
||||
>
|
||||
<div className="p-3 bg-blue-500/20 rounded-lg group-hover:bg-blue-500/30 transition-colors">
|
||||
<info.icon className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">{info.title}</h4>
|
||||
<p className="text-gray-400">{info.value}</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* Social Links */}
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-white mb-4">Follow Me</h4>
|
||||
<div className="flex space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
<social.icon size={20} />
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
placeholder="Your Message..."
|
||||
rows={5}
|
||||
required
|
||||
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 rounded-lg shadow-sm "
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="privacy"
|
||||
id="privacy"
|
||||
required
|
||||
className="h-5 w-5 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||||
/>
|
||||
<label htmlFor="privacy" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
I accept the{" "}
|
||||
<Link
|
||||
href="/privacy-policy"
|
||||
className="text-blue-800 transition-underline"
|
||||
>
|
||||
privacy policy
|
||||
</Link>.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 px-6 text-lg font-semibold text-white bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl hover:from-blue-600 hover:to-purple-600"
|
||||
{/* Contact Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="glass-card p-8 rounded-2xl"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
<h3 className="text-2xl font-bold text-white mb-6">Send Message</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="What's this about?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
|
||||
placeholder="Tell me more about your project..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full btn-primary py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>Sending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={20} />
|
||||
<span>Send Message</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Contact;
|
||||
|
||||
@@ -1,88 +1,165 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
"use client";
|
||||
|
||||
export default function Footer() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Github, Linkedin, Mail, Heart } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const Footer = () => {
|
||||
const [currentYear, setCurrentYear] = useState(2024);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 450); // Delay to start the animation
|
||||
setCurrentYear(new Date().getFullYear());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
const socialLinks = [
|
||||
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||
{ icon: Mail, href: 'mailto:contact@dki.one', label: 'Email' }
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Projects', href: '/projects' },
|
||||
{ name: 'About', href: '#about' },
|
||||
{ name: 'Contact', href: '#contact' }
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={`sticky- bottom-0 p-3 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl text-center text-gray-800 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
||||
>
|
||||
<div className={`flex flex-col md:flex-row items-center justify-between`}>
|
||||
<div className={`flex-col items-center`}>
|
||||
<h1 className="md:text-xl font-bold">Thank You for Visiting</h1>
|
||||
<p className="md:mt-1 text-lg">
|
||||
Connect with me on social platforms:
|
||||
</p>
|
||||
<div className="flex justify-center items-center space-x-4 mt-4">
|
||||
<Link
|
||||
aria-label={"Dennis Github"}
|
||||
href="https://github.com/Denshooter"
|
||||
target="_blank"
|
||||
<footer className="relative py-16 px-4 border-t border-gray-800">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/50 to-transparent"></div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
|
||||
<div className="md:col-span-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<svg
|
||||
className="w-10 h-10"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.757-1.333-1.757-1.09-.746.083-.73.083-.73 1.205.084 1.84 1.237 1.84 1.237 1.07 1.835 2.807 1.305 3.492.997.108-.774.42-1.305.763-1.605-2.665-.305-5.466-1.332-5.466-5.93 0-1.31.467-2.38 1.235-3.22-.123-.303-.535-1.527.117-3.18 0 0 1.008-.322 3.3 1.23.957-.266 1.98-.4 3-.405 1.02.005 2.043.14 3 .405 2.29-1.552 3.297-1.23 3.297-1.23.653 1.653.24 2.877.118 3.18.77.84 1.233 1.91 1.233 3.22 0 4.61-2.803 5.62-5.475 5.92.43.37.823 1.1.823 2.22v3.293c0 .32.218.694.825.577C20.565 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
aria-label={"Dennis Linked In"}
|
||||
href="https://linkedin.com/in/dkonkol"
|
||||
target="_blank"
|
||||
>
|
||||
<svg
|
||||
className="w-10 h-10"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M19 0h-14c-2.76 0-5 2.24-5 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5v-14c0-2.76-2.24-5-5-5zm-11 19h-3v-10h3v10zm-1.5-11.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5 11.5h-3v-5.5c0-1.38-1.12-2.5-2.5-2.5s-2.5 1.12-2.5 2.5v5.5h-3v-10h3v1.5c.83-1.17 2.17-1.5 3.5-1.5 2.48 0 4.5 2.02 4.5 4.5v5.5z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 md:absolute md:left-1/2 md:transform md:-translate-x-1/2">
|
||||
<button
|
||||
onClick={() => scrollToSection("about")}
|
||||
className="p-4 mt-4 md:px-4 md:my-6 text-white bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl hover:from-blue-600 hover:to-purple-600 transition"
|
||||
>
|
||||
Back to Top
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-col">
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/privacy-policy"
|
||||
className="text-blue-800 transition-underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href="/legal-notice"
|
||||
className="ml-4 text-blue-800 transition-underline"
|
||||
>
|
||||
Legal Notice
|
||||
</Link>
|
||||
<Link href="/" className="text-3xl font-bold gradient-text mb-4 inline-block">
|
||||
Dennis Konkol
|
||||
</Link>
|
||||
<p className="text-gray-400 mb-6 max-w-md leading-relaxed">
|
||||
A passionate software engineer and student based in Osnabrück, Germany.
|
||||
Creating innovative solutions that make a difference in the digital world.
|
||||
</p>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-all duration-200"
|
||||
>
|
||||
<social.icon size={20} />
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<p className="md:mt-4">© Dennis Konkol 2025</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
|
||||
<ul className="space-y-2">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-gray-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Legal</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
href="/legal-notice"
|
||||
className="text-gray-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Impressum
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/privacy-policy"
|
||||
className="text-gray-400 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Contact</h3>
|
||||
<div className="space-y-2 text-gray-400">
|
||||
<p>Osnabrück, Germany</p>
|
||||
<p>contact@dki.one</p>
|
||||
<p>+49 123 456 789</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="pt-8 border-t border-gray-800 text-center"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-gray-400">
|
||||
© {currentYear} Dennis Konkol. All rights reserved.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2 text-gray-400">
|
||||
<span>Made with</span>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<Heart size={16} className="text-red-500" />
|
||||
</motion.div>
|
||||
<span>in Germany</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,138 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Menu, X, Github, Linkedin, Mail } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Header() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const Header = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 50); // Delay to start the animation after Projects
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
} else {
|
||||
/*go to main page and scroll*/
|
||||
window.location.href = `/#${id}`;
|
||||
}
|
||||
};
|
||||
const navItems = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Projects', href: '/projects' },
|
||||
{ name: 'About', href: '#about' },
|
||||
{ name: 'Contact', href: '#contact' },
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||
{ icon: Mail, href: 'mailto:contact@dki.one', label: 'Email' },
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${isVisible ? "animate-fly-in" : "opacity-0"}`}>
|
||||
<div
|
||||
className={`fixed top-4 left-4 right-4 p-4 bg-white/45 text-gray-700 backdrop-blur-md shadow-xl rounded-2xl z-50 ${isSidebarOpen ? "transform -translate-y-full" : ""}`}
|
||||
<>
|
||||
<div className="particles">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="particle"
|
||||
style={{
|
||||
left: `${(i * 5.5) % 100}%`,
|
||||
animationDelay: `${(i * 0.8) % 20}s`,
|
||||
animationDuration: `${20 + (i * 0.4) % 10}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.header
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled ? 'glass' : 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<header className="w-full">
|
||||
<nav className="flex flex-row items-center px-4">
|
||||
<Link href="/" className="flex justify-start">
|
||||
<h1 className="text-xl md:text-2xl">Dennis Konkol</h1>
|
||||
</Link>
|
||||
<div className="flex-grow"></div>
|
||||
<button
|
||||
className="text-gray-700 hover:text-gray-900 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
aria-label={"Open menu"}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="hidden md:flex space-x-4 md:space-x-6">
|
||||
<button
|
||||
onClick={() => scrollToSection("about")}
|
||||
className="relative px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
||||
>
|
||||
About
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection("projects")}
|
||||
className="relative px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection("contact")}
|
||||
className="relative pl-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
||||
>
|
||||
Contact
|
||||
</button>
|
||||
<Link href="/" className="text-2xl font-bold gradient-text">
|
||||
DK
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{navItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.name}
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-gray-300 hover:text-white transition-colors duration-200 font-medium relative group"
|
||||
>
|
||||
{item.name}
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
|
||||
>
|
||||
<social.icon size={20} />
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`fixed inset-0 bg-black bg-opacity-50 transition-opacity ${isSidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
onClick={toggleSidebar}
|
||||
></div>
|
||||
|
||||
<div
|
||||
className={`fixed z-10 top-0 right-0 h-full bg-white w-1/3 transform transition-transform flex flex-col ${isSidebarOpen ? "translate-x-0" : "translate-x-full"}`}
|
||||
>
|
||||
<button
|
||||
aria-label={"Close menu"}
|
||||
className="absolute top-4 right-4 text-gray-700 hover:text-gray-900"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="pt-8 space-y-4 flex-grow">
|
||||
<button
|
||||
onClick={() => scrollToSection("about")}
|
||||
className="w-full px-4 py-2 pt-8 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
||||
>
|
||||
About
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection("projects")}
|
||||
className="w-full px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection("contact")}
|
||||
className="w-full px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
||||
>
|
||||
Contact
|
||||
</button>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500 p-4">© 2025 Dennis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="md:hidden glass"
|
||||
>
|
||||
<div className="px-4 py-6 space-y-4">
|
||||
{navItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.name}
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: navItems.indexOf(item) * 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block text-gray-300 hover:text-white transition-colors duration-200 font-medium py-2"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className="pt-4 border-t border-gray-700">
|
||||
<div className="flex space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
|
||||
>
|
||||
<social.icon size={20} />
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -1,53 +1,173 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
export default function Hero() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowDown, Code, Zap, Rocket } from 'lucide-react';
|
||||
|
||||
const Hero = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 150); // Delay to start the animation
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="about"
|
||||
className={`flex flex-col md:flex-row items-center justify-center pt-16 pb-16 px-6 text-gray-700 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
||||
const features = [
|
||||
{ icon: Code, text: 'Full-Stack Development' },
|
||||
{ icon: Zap, text: 'Modern Technologies' },
|
||||
{ icon: Rocket, text: 'Innovative Solutions' },
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 animated-bg"></div>
|
||||
|
||||
{/* Floating Elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute top-20 left-20 w-32 h-32 bg-blue-500/10 rounded-full blur-xl"
|
||||
initial={{ scale: 1, opacity: 0.3 }}
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-40 right-32 w-24 h-24 bg-purple-500/10 rounded-full blur-xl"
|
||||
initial={{ scale: 1.2, opacity: 0.6 }}
|
||||
animate={{
|
||||
scale: [1.2, 1, 1.2],
|
||||
opacity: [0.6, 0.3, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-32 left-1/3 w-40 h-40 bg-cyan-500/10 rounded-full blur-xl"
|
||||
initial={{ scale: 1, opacity: 0.4 }}
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
opacity: [0.4, 0.7, 0.4],
|
||||
}}
|
||||
transition={{
|
||||
duration: 6,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
||||
{/* Main Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-5xl md:text-7xl font-bold mb-6"
|
||||
>
|
||||
<div
|
||||
className="flex flex-col items-center p-8 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl max-w-lg text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900">
|
||||
Hi, I’m Dennis
|
||||
</h1>
|
||||
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-gray-700">
|
||||
Student & Software Engineer
|
||||
</h2>
|
||||
<h3 className="mt-1 text-lg md:text-xl text-gray-600">
|
||||
Based in Osnabrück, Germany
|
||||
</h3>
|
||||
<p className="mt-6 text-gray-800 text-lg leading-relaxed">
|
||||
Passionate about technology, coding, and solving real-world problems.
|
||||
I enjoy building innovative solutions and continuously expanding my
|
||||
knowledge.
|
||||
</p>
|
||||
<p className="mt-4 text-gray-700 text-base">
|
||||
Currently working on exciting projects that merge creativity with
|
||||
functionality. Always eager to learn and collaborate!
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex mt-8 md:mt-0 md:ml-12">
|
||||
<Image
|
||||
src="/images/me.jpg"
|
||||
alt="Image of Dennis"
|
||||
width={400}
|
||||
height={400}
|
||||
className="rounded-2xl shadow-lg shadow-gray-700 object-cover"
|
||||
loading="lazy" // Lazy Loading
|
||||
style={{width: "auto", height: "400px"}}
|
||||
sizes="(max-width: 640px) 640px, 828px" // Definiere, welche Bildgröße bei welcher Bildschirmgröße geladen wird
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<span className="gradient-text">Dennis Konkol</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto"
|
||||
>
|
||||
Student & Software Engineer based in Osnabrück, Germany
|
||||
</motion.p>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="text-lg text-gray-400 mb-12 max-w-3xl mx-auto leading-relaxed"
|
||||
>
|
||||
Passionate about technology, coding, and solving real-world problems.
|
||||
I create innovative solutions that make a difference.
|
||||
</motion.p>
|
||||
|
||||
{/* Features */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
className="flex flex-wrap justify-center gap-6 mb-12"
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.text}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 1 + index * 0.1 }}
|
||||
whileHover={{ scale: 1.05, y: -5 }}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-full glass-card"
|
||||
>
|
||||
<feature.icon className="w-5 h-5 text-blue-400" />
|
||||
<span className="text-gray-300 font-medium">{feature.text}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 1.2 }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
>
|
||||
<motion.a
|
||||
href="#projects"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-primary px-8 py-4 text-lg font-semibold"
|
||||
>
|
||||
View My Work
|
||||
</motion.a>
|
||||
|
||||
<motion.a
|
||||
href="#contact"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-8 py-4 text-lg font-semibold border-2 border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Get In Touch
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1, delay: 1.5 }}
|
||||
className="mt-16 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="flex flex-col items-center text-gray-400"
|
||||
>
|
||||
<span className="text-sm mb-2">Scroll Down</span>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
|
||||
@@ -1,91 +1,178 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Github, Calendar } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Project {
|
||||
slug: string;
|
||||
id: string;
|
||||
id: number;
|
||||
title: string;
|
||||
feature_image: string;
|
||||
visibility: string;
|
||||
published_at: string;
|
||||
updated_at: string;
|
||||
html: string;
|
||||
reading_time: number;
|
||||
meta_description: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
category: string;
|
||||
date: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
}
|
||||
|
||||
interface ProjectsData {
|
||||
posts: Project[];
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const Projects = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/fetchAllProjects");
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch projects: ${response.statusText}`);
|
||||
return [];
|
||||
}
|
||||
const projectsData = (await response.json()) as ProjectsData;
|
||||
if (!projectsData || !projectsData.posts) {
|
||||
console.error("Invalid projects data");
|
||||
return;
|
||||
}
|
||||
setProjects(projectsData.posts);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 250); // Delay to start the animation after Hero
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
// Load projects from localStorage
|
||||
useEffect(() => {
|
||||
const savedProjects = localStorage.getItem('portfolio-projects');
|
||||
if (savedProjects) {
|
||||
setProjects(JSON.parse(savedProjects));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="projects"
|
||||
className={`p-10 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
||||
>
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900">Projects</h2>
|
||||
<div className="mt-6">
|
||||
{isVisible && (
|
||||
<ResponsiveMasonry
|
||||
columnsCountBreakPoints={{ 350: 1, 750: 2, 900: 3 }}
|
||||
>
|
||||
<Masonry gutter="16px">
|
||||
{projects.map((project, index) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={{
|
||||
pathname: `/projects/${project.slug}`,
|
||||
query: { project: JSON.stringify(project) },
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="project-card"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-gray-800">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{project.meta_description}
|
||||
</p>
|
||||
<section id="projects" className="py-20 px-4 relative">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||
Featured Projects
|
||||
</h2>
|
||||
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||
Here are some of my recent projects that showcase my skills and passion for creating innovative solutions.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{projects.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
whileHover={{ y: -10 }}
|
||||
className={`group relative overflow-hidden rounded-2xl glass-card card-hover ${
|
||||
project.featured ? 'ring-2 ring-blue-500/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20" />
|
||||
<div className="absolute inset-0 bg-gray-800/50 flex flex-col items-center justify-center p-4">
|
||||
<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">
|
||||
{project.title.split(' ').map(word => word[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-400 text-center leading-tight">
|
||||
{project.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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.trim() !== '' && project.github !== '#' && (
|
||||
<motion.a
|
||||
href={project.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-gray-800/80 rounded-lg text-white hover:bg-gray-700/80 transition-colors"
|
||||
>
|
||||
<Github size={20} />
|
||||
</motion.a>
|
||||
)}
|
||||
{project.live && project.live.trim() !== '' && project.live !== '#' && (
|
||||
<motion.a
|
||||
href={project.live}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-blue-600/80 rounded-lg text-white hover:bg-blue-500/80 transition-colors"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
</motion.a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-gray-400">
|
||||
<Calendar size={16} />
|
||||
<span className="text-sm">{project.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4 leading-relaxed">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors font-medium"
|
||||
>
|
||||
<span>View Project</span>
|
||||
<ExternalLink size={16} />
|
||||
</Link>
|
||||
))}
|
||||
</Masonry>
|
||||
</ResponsiveMasonry>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="btn-primary px-8 py-4 text-lg font-semibold inline-flex items-center space-x-2"
|
||||
>
|
||||
<span>View All Projects</span>
|
||||
<ExternalLink size={20} />
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
|
||||
458
app/globals.css
458
app/globals.css
@@ -1,216 +1,364 @@
|
||||
/* app/globals.css */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #0f0f0f;
|
||||
--card-foreground: #fafafa;
|
||||
--popover: #0f0f0f;
|
||||
--popover-foreground: #fafafa;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f1f5f9;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #64748b;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f1f5f9;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #f8fafc;
|
||||
--border: #1e293b;
|
||||
--input: #1e293b;
|
||||
--ring: #3b82f6;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Glassmorphism Effects */
|
||||
.glass {
|
||||
background: rgba(15, 15, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(15, 15, 15, 0.6);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.gradient-text-blue {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Animated Background */
|
||||
.animated-bg {
|
||||
background: linear-gradient(-45deg, #0f0f0f, #1a1a1a, #0f0f0f, #1a1a1a);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* Floating Animation */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glow Effects */
|
||||
.glow {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.glow-hover:hover {
|
||||
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* Particle Background */
|
||||
.particles {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: rgba(59, 130, 246, 0.5);
|
||||
border-radius: 50%;
|
||||
animation: particleFloat 20s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes particleFloat {
|
||||
0% {
|
||||
transform: translateY(100vh) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100vh) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown Styles */
|
||||
.markdown {
|
||||
color: #ffffff !important;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #444;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: bold;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.7;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.markdown ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
.markdown img:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.markdown ol {
|
||||
list-style-type: decimal;
|
||||
margin-left: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
.markdown ul, .markdown ol {
|
||||
margin: 1rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
margin: 0.5rem 0;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
border-left: 4px solid #ccc;
|
||||
color: #777;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-style: italic;
|
||||
background-color: #f9f9f9;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 8px;
|
||||
font-style: italic;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.bg-radiant-animated {
|
||||
background: radial-gradient(circle at 20% 20%, #ff8185, transparent 25%),
|
||||
radial-gradient(circle at 80% 80%, #ffaa91, transparent 25%),
|
||||
radial-gradient(circle at 50% 50%, #fb7fd9, transparent 25%),
|
||||
radial-gradient(circle at 30% 70%, #9b6fff, transparent 25%),
|
||||
radial-gradient(circle at 70% 30%, #ff8edf, transparent 25%);
|
||||
background-size: 200% 200%;
|
||||
animation: backgroundAnimation 60s ease infinite alternate;
|
||||
.markdown code {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6 !important;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.bg-radiant {
|
||||
background: radial-gradient(circle at 20% 20%, #ff8185, transparent 25%),
|
||||
radial-gradient(circle at 80% 80%, #ffaa91, transparent 25%),
|
||||
radial-gradient(circle at 50% 50%, #fb7fd9, transparent 25%),
|
||||
radial-gradient(circle at 30% 70%, #9b6fff, transparent 25%),
|
||||
radial-gradient(circle at 70% 30%, #ff8edf, transparent 25%);
|
||||
background-size: cover;
|
||||
.markdown pre {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
@keyframes backgroundAnimation {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
.markdown pre code {
|
||||
background: none;
|
||||
color: #ffffff !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
.markdown a {
|
||||
color: #3b82f6 !important;
|
||||
text-decoration: underline;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
.markdown a:hover {
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
.markdown strong {
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
.markdown em {
|
||||
color: #e5e7eb !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.react-cookie-consent .content-wrapper {
|
||||
flex: 1;
|
||||
margin-right: 1rem;
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.react-cookie-consent .button-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.react-cookie-consent .button-wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
.btn-primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.transition-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
.btn-primary:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.transition-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -2px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: currentColor;
|
||||
/* Card Hover Effects */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Loading Animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Fade In Animation */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.transition-underline:hover::after {
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
opacity: 1 !important;
|
||||
transition: opacity 0.5s ease;
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.markdown h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
@keyframes flyIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.markdown h2 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.animate-fly-in {
|
||||
animation: flyIn 1s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fadeOut 3s forwards;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-5px);
|
||||
.markdown h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,58 @@
|
||||
// app/layout.tsx
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
import {Metadata} from "next";
|
||||
|
||||
import {Roboto} from "next/font/google";
|
||||
import { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import React from "react";
|
||||
//import ClientCookieConsentBanner from "./components/ClientCookieConsentBanner";
|
||||
|
||||
const roboto = Roboto({
|
||||
variable: "--font-roboto",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
defer
|
||||
src="https://umami.denshooter.de/script.js"
|
||||
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
|
||||
></script>
|
||||
<meta charSet="utf-8"/>
|
||||
<title>Dennis Konkol's Portfolio</title>
|
||||
</head>
|
||||
<body className={roboto.variable}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
defer
|
||||
src="https://umami.denshooter.de/script.js"
|
||||
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
|
||||
></script>
|
||||
<meta charSet="utf-8"/>
|
||||
<title>Dennis Konkol's Portfolio</title>
|
||||
</head>
|
||||
<body className={inter.variable}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dennis Konkol | Portfolio",
|
||||
description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.",
|
||||
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"],
|
||||
authors: [{name: "Dennis Konkol", url: "https://dki.one"}],
|
||||
openGraph: {
|
||||
title: "Dennis Konkol | Portfolio",
|
||||
description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.",
|
||||
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"],
|
||||
authors: [{name: "Dennis Konkol", url: "https://dki.one"}],
|
||||
openGraph: {
|
||||
title: "Dennis Konkol | Portfolio",
|
||||
description: "Explore my projects and get in touch!",
|
||||
url: "https://dki.one",
|
||||
siteName: "Dennis Konkol Portfolio",
|
||||
images: [
|
||||
{
|
||||
url: "https://dki.one/api/og",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dennis Konkol Portfolio",
|
||||
},
|
||||
],
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Dennis Konkol | Portfolio",
|
||||
description: "Student & Software Engineer based in Osnabrück, Germany.",
|
||||
images: ["https://dki.one/api/og"],
|
||||
},
|
||||
description: "Explore my projects and get in touch!",
|
||||
url: "https://dki.one",
|
||||
siteName: "Dennis Konkol Portfolio",
|
||||
images: [
|
||||
{
|
||||
url: "https://dki.one/api/og",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dennis Konkol Portfolio",
|
||||
},
|
||||
],
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Dennis Konkol | Portfolio",
|
||||
description: "Student & Software Engineer based in Osnabrück, Germany.",
|
||||
images: ["https://dki.one/api/og"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,66 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Header from "../components/Header";
|
||||
import Footer_Back from "../components/Footer_Back";
|
||||
import Footer from "../components/Footer";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LegalNotice() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-radiant-animated">
|
||||
<div className="min-h-screen animated-bg">
|
||||
<Header />
|
||||
<div className="h-10"></div>
|
||||
<main className="flex-grow p-10">
|
||||
<h1 className="text-3xl font-bold">Impressum</h1>
|
||||
<p className="mt-4">
|
||||
<strong>
|
||||
Verantwortlicher für die Inhalte dieser Website (auch Redaktionell):{" "}
|
||||
<br />
|
||||
</strong>
|
||||
<strong>Name:</strong> Dennis Konkol
|
||||
<br />
|
||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück,
|
||||
Deutschland
|
||||
<br />
|
||||
<strong>E-Mail:</strong>{" "}
|
||||
<Link href={"mailto:info@dki.one"} className="transition-underline">
|
||||
info@dki.one
|
||||
</Link>{" "}
|
||||
<br />
|
||||
<strong>Website:</strong>{" "}
|
||||
<Link href={"https://www.dki.one"} className="transition-underline">
|
||||
{" "}
|
||||
dki.one{" "}
|
||||
<main className="max-w-4xl mx-auto px-4 py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Home</span>
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-6">Haftung für Links</h2>
|
||||
<p className="mt-2">
|
||||
Meine Website enthält Links auf externe Websites. Ich habe keinen
|
||||
Einfluss auf die Inhalte dieser Websites und kann daher keine Gewähr
|
||||
übernehmen. Für die Inhalte der verlinkten Seiten ist stets der
|
||||
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe
|
||||
ich die verlinkten Seiten zum Zeitpunkt der Verlinkung auf mögliche
|
||||
Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde ich
|
||||
derartige Links umgehend entfernen.
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||
Impressum
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-6">Urheberrecht</h2>
|
||||
<p className="mt-2">
|
||||
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs,
|
||||
stehen unter Urheberrechtsschutz. Jegliche Nutzung ohne vorherige
|
||||
schriftliche Zustimmung des Urhebers ist verboten.
|
||||
</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="glass-card p-8 rounded-2xl space-y-6"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||
Verantwortlicher für die Inhalte dieser Website
|
||||
</h2>
|
||||
<div className="space-y-2 text-gray-300">
|
||||
<p><strong>Name:</strong> Dennis Konkol</p>
|
||||
<p><strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland</p>
|
||||
<p><strong>E-Mail:</strong> <Link href="mailto:info@dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">info@dki.one</Link></p>
|
||||
<p><strong>Website:</strong> <Link href="https://www.dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">dki.one</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-6">Gewährleistung</h2>
|
||||
<p className="mt-2">
|
||||
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als
|
||||
Diensteanbieter kann ich keine Gewähr übernehmen für Schäden, die
|
||||
entstehen können, durch den Zugriff oder die Nutzung dieser Website.
|
||||
</p>
|
||||
<p className="font-semibold mt-6">Letzte Aktualisierung: 12.02.2025</p>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">Haftung für Links</h2>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser Websites
|
||||
und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der Betreiber oder
|
||||
Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum Zeitpunkt der Verlinkung
|
||||
auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">Urheberrecht</h2>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter Urheberrechtsschutz.
|
||||
Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist verboten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">Gewährleistung</h2>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine
|
||||
Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser Website.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-700">
|
||||
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</main>
|
||||
<Footer_Back />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// app/page.tsx
|
||||
"use client";
|
||||
|
||||
import Header from "./components/Header";
|
||||
@@ -10,7 +9,7 @@ import Script from "next/script";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-radiant-animated">
|
||||
<div className="min-h-screen animated-bg">
|
||||
<Script
|
||||
id={"structured-data"}
|
||||
type="application/ld+json"
|
||||
@@ -34,13 +33,12 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
<Header />
|
||||
<div className="h-10"></div>
|
||||
<main>
|
||||
<Hero />
|
||||
<Projects />
|
||||
<Contact />
|
||||
<Footer />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Header from "../components/Header";
|
||||
import Footer_Back from "../components/Footer_Back";
|
||||
import Footer from "../components/Footer";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 350);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen flex flex-col bg-radiant-animated ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
||||
>
|
||||
<div className="min-h-screen animated-bg">
|
||||
<Header />
|
||||
<div className="h-10"></div>
|
||||
<main className="flex-grow p-10">
|
||||
<h1 className="text-3xl font-bold">Datenschutzerklärung</h1>
|
||||
<p className="mt-4">
|
||||
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser
|
||||
Datenschutzerklärung informiere ich Sie über die Verarbeitung
|
||||
personenbezogener Daten im Rahmen meines Internet-Angebots.
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold mt-6">
|
||||
Verantwortlicher für die Datenverarbeitung
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
<strong>Name:</strong> Dennis Konkol <br />
|
||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück,
|
||||
Deutschland <br />
|
||||
<strong>E-Mail:</strong>{" "}
|
||||
<Link className="transition-underline" href={"mailto:info@dki.one"}>
|
||||
info@dki.one
|
||||
</Link>{" "}
|
||||
<br />
|
||||
<strong>Website:</strong>{" "}
|
||||
<Link className="transition-underline" href={"https://www.dki.one"}>
|
||||
{" "}
|
||||
dki.one{" "}
|
||||
</Link>{" "}
|
||||
<br />
|
||||
<br />
|
||||
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener
|
||||
Daten durch den oben genannten Verantwortlichen.
|
||||
</p>
|
||||
<main className="max-w-4xl mx-auto px-4 py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<motion.a
|
||||
href="/"
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Home</span>
|
||||
</motion.a>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||
Datenschutzerklärung
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="glass-card p-8 rounded-2xl space-y-6 text-white"
|
||||
>
|
||||
<div>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
|
||||
über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||
Verantwortlicher für die Datenverarbeitung
|
||||
</h2>
|
||||
<div className="space-y-2 text-gray-300">
|
||||
<p><strong>Name:</strong> Dennis Konkol</p>
|
||||
<p><strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland</p>
|
||||
<p><strong>E-Mail:</strong> <Link className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dki.one">info@dki.one</Link></p>
|
||||
<p><strong>Website:</strong> <Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dki.one">dki.one</Link></p>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed mt-4">
|
||||
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten Verantwortlichen.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mt-6">
|
||||
Erfassung allgemeiner Informationen beim Besuch meiner Website
|
||||
</h2>
|
||||
@@ -221,9 +230,12 @@ export default function PrivacyPolicy() {
|
||||
berücksichtigen. Die jeweils aktuelle Datenschutzerklärung finden Sie
|
||||
auf meiner Website.
|
||||
</p>
|
||||
<p className="mt-6 font-bold">Letzte Aktualisierung: 12.02.2025</p>
|
||||
<div className="pt-4 border-t border-gray-700">
|
||||
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</main>
|
||||
<Footer_Back />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
useParams,
|
||||
usePathname,
|
||||
} from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import Footer_Back from "@/app/components/Footer_Back";
|
||||
import Header from "@/app/components/Header";
|
||||
import Image from "next/image";
|
||||
import "@/app/styles/ghostContent.css"; // Import the global styles
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Calendar, Tag, ArrowLeft, Github as GithubIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
interface Project {
|
||||
slug: string;
|
||||
id: string;
|
||||
id: number;
|
||||
title: string;
|
||||
feature_image: string;
|
||||
visibility: string;
|
||||
published_at: string;
|
||||
updated_at: string;
|
||||
html: string;
|
||||
reading_time: number;
|
||||
meta_description: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
category: string;
|
||||
date: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
}
|
||||
|
||||
const ProjectDetails = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const ProjectDetail = () => {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const slug = params.slug as string;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load project from localStorage by slug
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 150); // Delay to start the animation
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const projectData = searchParams.get("project");
|
||||
if (projectData) {
|
||||
setProject(JSON.parse(projectData as string));
|
||||
// Remove the project data from the URL without reloading the page
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("project");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
} else {
|
||||
// Fetch project data based on slug from URL
|
||||
const slug = params.slug as string;
|
||||
try {
|
||||
fetchProjectData(slug);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError("Failed to fetch project data");
|
||||
const savedProjects = localStorage.getItem('portfolio-projects');
|
||||
if (savedProjects) {
|
||||
const projects = JSON.parse(savedProjects);
|
||||
const foundProject = projects.find((p: Project) =>
|
||||
p.title.toLowerCase().replace(/[^a-z0-9]+/g, '-') === slug
|
||||
);
|
||||
if (foundProject) {
|
||||
setProject(foundProject);
|
||||
}
|
||||
}
|
||||
}, [searchParams, router, params, pathname]);
|
||||
|
||||
const fetchProjectData = async (slug: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/fetchProject?slug=${slug}`);
|
||||
if (!response.ok) {
|
||||
setError("Failed to fetch project Data");
|
||||
}
|
||||
const projectData = (await response.json()) as { posts: Project[] };
|
||||
if (
|
||||
!projectData ||
|
||||
!projectData.posts ||
|
||||
projectData.posts.length === 0
|
||||
) {
|
||||
setError("Project not found");
|
||||
}
|
||||
setProject(projectData.posts[0]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project data:", error);
|
||||
setError("Project not found");
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-radiant">
|
||||
<Header />
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="text-center p-10 bg-white dark:bg-gray-700 rounded shadow-md">
|
||||
<h1 className="text-6xl font-bold text-gray-800 dark:text-white">
|
||||
404
|
||||
</h1>
|
||||
<p className="mt-4 text-xl text-gray-600 dark:text-gray-300">
|
||||
{error}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-block text-blue-500 hover:underline"
|
||||
>
|
||||
Go Back Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Footer_Back />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-radiant">
|
||||
<Header />
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-32 w-32"></div>
|
||||
<div className="min-h-screen animated-bg flex items-center justify-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>
|
||||
<p className="text-gray-400">Loading project...</p>
|
||||
</div>
|
||||
<Footer_Back />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const featureImageUrl = project.feature_image
|
||||
? `/api/fetchImage?url=${encodeURIComponent(project.feature_image)}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen flex flex-col bg-radiant ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
||||
>
|
||||
<Header />
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-center mt-14 md:mt-28 px-4 md:px-0">
|
||||
{featureImageUrl && (
|
||||
<div className="relative w-full max-w-4xl h-0 pb-[56.25%] rounded-2xl overflow-hidden">
|
||||
<Image
|
||||
src={featureImageUrl}
|
||||
alt={project.title}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
className="rounded-2xl"
|
||||
priority={true}
|
||||
/>
|
||||
<div className="min-h-screen animated-bg">
|
||||
<div className="max-w-4xl mx-auto px-4 py-20">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Projects</span>
|
||||
</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>
|
||||
<div className="flex items-center justify-center mt-4">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-600">
|
||||
{project.title}
|
||||
</h1>
|
||||
</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 */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<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 !== "#" && (
|
||||
<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>
|
||||
|
||||
{/* Project Content */}
|
||||
<div className="p-10 pt-12">
|
||||
<div className="flex flex-col p-8 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl">
|
||||
<div
|
||||
className="content mt-4 text-gray-600 text-lg leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: project.html }}
|
||||
></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="glass-card p-8 rounded-2xl"
|
||||
>
|
||||
<div className="markdown prose prose-invert max-w-none text-white">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({children}) => <h1 className="text-3xl font-bold text-white mb-4">{children}</h1>,
|
||||
h2: ({children}) => <h2 className="text-2xl font-semibold text-white mb-3">{children}</h2>,
|
||||
h3: ({children}) => <h3 className="text-xl font-semibold text-white mb-2">{children}</h3>,
|
||||
p: ({children}) => <p className="text-gray-300 mb-3 leading-relaxed">{children}</p>,
|
||||
ul: ({children}) => <ul className="list-disc list-inside text-gray-300 mb-3 space-y-1">{children}</ul>,
|
||||
ol: ({children}) => <ol className="list-decimal list-inside text-gray-300 mb-3 space-y-1">{children}</ol>,
|
||||
li: ({children}) => <li className="text-gray-300">{children}</li>,
|
||||
a: ({href, children}) => (
|
||||
<a href={href} className="text-blue-400 hover:text-blue-300 underline transition-colors" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
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>,
|
||||
blockquote: ({children}) => <blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-300 mb-3">{children}</blockquote>,
|
||||
strong: ({children}) => <strong className="font-semibold text-white">{children}</strong>,
|
||||
em: ({children}) => <em className="italic text-gray-300">{children}</em>
|
||||
}}
|
||||
>
|
||||
{project.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<Footer_Back />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetails;
|
||||
export default ProjectDetail;
|
||||
|
||||
207
app/projects/page.tsx
Normal file
207
app/projects/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Github, Calendar, ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
category: string;
|
||||
date: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
}
|
||||
|
||||
const ProjectsPage = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
// Load projects from localStorage
|
||||
useEffect(() => {
|
||||
const savedProjects = localStorage.getItem('portfolio-projects');
|
||||
if (savedProjects) {
|
||||
setProjects(JSON.parse(savedProjects));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categories = ["All", "Web Development", "Full-Stack", "Web Application", "Mobile App"];
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredProjects = selectedCategory === "All"
|
||||
? projects
|
||||
: projects.filter(project => project.category === selectedCategory);
|
||||
|
||||
console.log('Selected category:', selectedCategory);
|
||||
console.log('Filtered projects:', filteredProjects);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen animated-bg">
|
||||
<div className="max-w-7xl mx-auto px-4 py-20">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Home</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6 gradient-text">
|
||||
My Projects
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400 max-w-3xl">
|
||||
Explore my portfolio of projects, from web applications to mobile apps.
|
||||
Each project showcases different skills and technologies.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'bg-gray-800/50 text-gray-300 hover:bg-gray-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredProjects.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
whileHover={{ y: -10 }}
|
||||
className="group relative overflow-hidden rounded-2xl glass-card card-hover"
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20" />
|
||||
<div className="absolute inset-0 bg-gray-800/50 flex flex-col items-center justify-center p-4">
|
||||
<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">
|
||||
{project.title.split(' ').map(word => word[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-400 text-center leading-tight">
|
||||
{project.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<motion.a
|
||||
href={project.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-gray-800/80 rounded-lg text-white hover:bg-gray-700/80 transition-colors"
|
||||
>
|
||||
<Github size={20} />
|
||||
</motion.a>
|
||||
)}
|
||||
{project.live && project.live !== "#" && (
|
||||
<motion.a
|
||||
href={project.live}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-blue-600/80 rounded-lg text-white hover:bg-blue-500/80 transition-colors"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
</motion.a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-gray-400">
|
||||
<Calendar size={16} />
|
||||
<span className="text-sm">{project.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4 leading-relaxed">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors font-medium"
|
||||
>
|
||||
<span>View Project</span>
|
||||
<ExternalLink size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
1513
package-lock.json
generated
1513
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -13,17 +13,22 @@
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@prisma/client": "^6.3.1",
|
||||
"@tryghost/content-api": "^1.11.21",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"framer-motion": "^11.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.1.7",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-responsive-masonry": "^2.7.1"
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -32,10 +37,12 @@
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-responsive-masonry": "^2.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.7",
|
||||
|
||||
Reference in New Issue
Block a user