feat: add dynamic site settings, hero media, admin panels, and database integration

This commit is contained in:
mstfyldz
2026-06-05 16:59:05 +03:00
parent 121e127f6b
commit 94182b6bc5
27 changed files with 1894 additions and 52 deletions
+20 -6
View File
@@ -7,21 +7,35 @@ import InstagramFeed from "@/components/InstagramFeed";
import Contact from "@/components/Contact";
import Footer from "@/components/Footer";
import { getDictionary } from "../dictionaries";
import { prisma } from "@/lib/db";
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const dict = await getDictionary(lang);
const dbPhotos = await prisma.gallery.findMany({
orderBy: { createdAt: 'desc' }
});
const dbServices = await prisma.service.findMany({
orderBy: { createdAt: 'asc' }
});
const heroMediaList = await prisma.heroMedia.findMany();
const dbHeroMedia = heroMediaList.length > 0 ? heroMediaList[0] : null;
const dbSettings = await prisma.siteSettings.findFirst();
return (
<main className="min-h-screen bg-brand-white">
<Header dict={dict} lang={lang} />
<Hero dict={dict} />
<Header dict={dict} lang={lang} dbSettings={dbSettings} />
<Hero dict={dict} dbHeroMedia={dbHeroMedia} />
<About dict={dict} />
<Services dict={dict} />
<Gallery dict={dict} />
<Services dict={dict} dbServices={dbServices} />
<Gallery dict={dict} dbPhotos={dbPhotos} />
<InstagramFeed dict={dict} />
<Contact dict={dict} />
<Footer dict={dict} />
<Contact dict={dict} dbSettings={dbSettings} />
<Footer dict={dict} dbSettings={dbSettings} />
</main>
);
}
+102
View File
@@ -0,0 +1,102 @@
'use server';
import { prisma } from '@/lib/db';
import { createSession, deleteSession } from '@/lib/auth';
import bcrypt from 'bcryptjs';
// Auth Actions
export async function login(formData: FormData) {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
return { error: 'Kullanıcı adı ve şifre zorunludur' };
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return { error: 'Hatalı kullanıcı adı veya şifre' };
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return { error: 'Hatalı kullanıcı adı veya şifre' };
}
await createSession(user.id);
return { success: true };
}
export async function logout() {
await deleteSession();
}
// Contact Actions
export async function markContactAsRead(id: string) {
await prisma.contactMessage.update({
where: { id },
data: { isRead: true }
});
}
export async function deleteContact(id: string) {
await prisma.contactMessage.delete({ where: { id } });
}
// Gallery Actions
export async function addGalleryItem(data: { title: string, category: string, imageUrl: string }) {
await prisma.gallery.create({ data });
}
export async function deleteGalleryItem(id: string) {
await prisma.gallery.delete({ where: { id } });
}
// Service Actions
export async function addServiceItem(data: { title: string, description: string, iconUrl?: string }) {
await prisma.service.create({ data });
}
export async function deleteServiceItem(id: string) {
await prisma.service.delete({ where: { id } });
}
export async function updateServiceItem(id: string, data: { title: string, description: string, iconUrl?: string }) {
await prisma.service.update({
where: { id },
data
});
}
// Hero Actions
export async function updateHeroMedia(url: string, type: string) {
// We only want one hero media. We can delete all and create a new one.
await prisma.heroMedia.deleteMany({});
await prisma.heroMedia.create({
data: { url, type }
});
}
// Site Settings Actions
export async function updateSiteSettings(data: {
logoUrl?: string;
phone?: string;
email?: string;
addressText1?: string;
addressText2?: string;
workingHours?: string;
instagramUrl?: string;
facebookUrl?: string;
youtubeUrl?: string;
whatsappNumber?: string;
}) {
const existing = await prisma.siteSettings.findFirst();
if (existing) {
await prisma.siteSettings.update({
where: { id: existing.id },
data
});
} else {
await prisma.siteSettings.create({ data });
}
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { LayoutDashboard, Image as ImageIcon, Briefcase, Mail, LogOut, Settings } from 'lucide-react';
import { logout } from '../actions';
const navItems = [
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ name: 'Hero (Ana Ekran)', href: '/admin/hero', icon: ImageIcon },
{ name: 'Galeri', href: '/admin/gallery', icon: ImageIcon },
{ name: 'Hizmetler', href: '/admin/services', icon: Briefcase },
{ name: 'İletişim Mesajları', href: '/admin/contact', icon: Mail },
{ name: 'Site Ayarları', href: '/admin/settings', icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const handleLogout = async () => {
await logout();
router.push('/admin/login');
};
return (
<aside className="w-64 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 hidden md:flex flex-col h-screen sticky top-0">
<div className="p-6 border-b border-slate-200 dark:border-slate-800">
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">Admin Panel</h2>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 font-medium'
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-white'
}`}
>
<item.icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
);
})}
</nav>
<div className="p-4 border-t border-slate-200 dark:border-slate-800">
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-slate-600 hover:bg-red-50 hover:text-red-600 dark:text-slate-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors"
>
<LogOut className="w-5 h-5" />
<span>Çıkış Yap</span>
</button>
</div>
</aside>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { Trash2, CheckCircle } from 'lucide-react';
import { deleteContact, markContactAsRead } from '../actions';
import { revalidatePath } from 'next/cache';
export default async function ContactAdminPage() {
const session = await getSession();
if (!session) redirect('/admin/login');
const messages = await prisma.contactMessage.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<div>
<h1 className="text-3xl font-bold mb-8">İletişim Mesajları</h1>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-800">
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">Tarih</th>
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">Gönderen</th>
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">E-posta</th>
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">Konu</th>
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">Mesaj</th>
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">Durum</th>
<th className="p-4 font-semibold text-sm text-slate-600 dark:text-slate-300">İşlem</th>
</tr>
</thead>
<tbody>
{messages.map(msg => (
<tr key={msg.id} className="border-b border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="p-4 text-sm">{msg.createdAt.toLocaleDateString('tr-TR')}</td>
<td className="p-4 text-sm font-medium">{msg.name}</td>
<td className="p-4 text-sm text-slate-500 dark:text-slate-400">{msg.email}</td>
<td className="p-4 text-sm">{msg.subject || '-'}</td>
<td className="p-4 text-sm max-w-xs truncate" title={msg.message}>{msg.message}</td>
<td className="p-4 text-sm">
{msg.isRead ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Okundu
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
Yeni
</span>
)}
</td>
<td className="p-4 text-sm">
<div className="flex items-center space-x-2">
{!msg.isRead && (
<form action={async () => {
'use server';
await markContactAsRead(msg.id);
revalidatePath('/admin/contact');
}}>
<button type="submit" className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="Okundu İşaretle">
<CheckCircle className="w-5 h-5" />
</button>
</form>
)}
<form action={async () => {
'use server';
await deleteContact(msg.id);
revalidatePath('/admin/contact');
}}>
<button type="submit" className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Sil">
<Trash2 className="w-5 h-5" />
</button>
</form>
</div>
</td>
</tr>
))}
{messages.length === 0 && (
<tr>
<td colSpan={7} className="p-8 text-center text-slate-500">Hiç mesaj bulunamadı.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { Trash2, Plus, Image as ImageIcon } from 'lucide-react';
import { addGalleryItem, deleteGalleryItem } from '../actions';
import { revalidatePath } from 'next/cache';
import { uploadImage } from '@/lib/cloudinary';
export default async function GalleryAdminPage() {
const session = await getSession();
if (!session) redirect('/admin/login');
const galleryItems = await prisma.gallery.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Galeri Yönetimi</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Ekleme Formu */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6 sticky top-8">
<h2 className="text-xl font-bold mb-6">Yeni Resim Ekle</h2>
<form action={async (formData: FormData) => {
'use server';
const title = formData.get('title') as string;
const category = formData.get('category') as string;
const imageFile = formData.get('image') as File;
if (title && category && imageFile && imageFile.size > 0) {
const imageUrl = await uploadImage(imageFile);
await addGalleryItem({ title, category, imageUrl });
revalidatePath('/admin/gallery');
}
}} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Başlık</label>
<input type="text" name="title" required className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Kategori</label>
<select name="category" required className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Seçiniz...</option>
<option value="beach">Plaj (Beach)</option>
<option value="restaurant">Restoran (Restaurant)</option>
<option value="events">Etkinlikler (Events)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Resim Yükle</label>
<input type="file" name="image" accept="image/*" required className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" />
</div>
<button type="submit" className="w-full flex items-center justify-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-lg transition-colors">
<Plus className="w-5 h-5" />
<span>Ekle</span>
</button>
</form>
</div>
</div>
{/* Listeleme */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6">
<h2 className="text-xl font-bold mb-6">Mevcut Resimler ({galleryItems.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{galleryItems.map(item => (
<div key={item.id} className="group relative rounded-xl overflow-hidden border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
<div className="aspect-video w-full overflow-hidden bg-slate-200 dark:bg-slate-700">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.imageUrl} alt={item.title} className="w-full h-full object-cover" />
</div>
<div className="p-3">
<h3 className="font-semibold text-slate-800 dark:text-white truncate">{item.title}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{item.category}</p>
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<form action={async () => {
'use server';
await deleteGalleryItem(item.id);
revalidatePath('/admin/gallery');
}}>
<button type="submit" className="p-2 bg-red-600 hover:bg-red-700 text-white rounded-lg shadow-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</form>
</div>
</div>
))}
{galleryItems.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-500">
Galeriye henüz resim eklenmemiş.
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { Image as ImageIcon, Video, Upload } from 'lucide-react';
import { updateHeroMedia } from '../actions';
import { revalidatePath } from 'next/cache';
import { uploadImage } from '@/lib/cloudinary';
export default async function HeroAdminPage() {
const session = await getSession();
if (!session) redirect('/admin/login');
const heroMediaList = await prisma.heroMedia.findMany();
const currentHero = heroMediaList.length > 0 ? heroMediaList[0] : null;
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Hero (Ana Ekran) Yönetimi</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Yükleme Formu */}
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6 h-fit">
<h2 className="text-xl font-bold mb-6">Yeni Medya Yükle</h2>
<form action={async (formData: FormData) => {
'use server';
const file = formData.get('media') as File;
if (file && file.size > 0) {
const url = await uploadImage(file);
// Cloudinary returns video format URLs or we can infer from file type
const type = file.type.startsWith('video/') ? 'video' : 'image';
await updateHeroMedia(url, type);
revalidatePath('/admin/hero');
}
}} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Resim veya Video Seçin (Maksimum 50MB)
</label>
<input
type="file"
name="media"
accept="image/*,video/*"
required
className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="mt-2 text-xs text-slate-500">
Yeni bir dosya yüklediğinizde, mevcut olan otomatik olarak silinir ve yerini alır. Sadece bir adet Hero medyası bulunabilir.
</p>
</div>
<button type="submit" className="w-full flex items-center justify-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-lg transition-colors">
<Upload className="w-5 h-5" />
<span>Yükle ve Güncelle</span>
</button>
</form>
</div>
{/* Mevcut Medya */}
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6">
<h2 className="text-xl font-bold mb-6">Aktif Medya</h2>
{currentHero ? (
<div className="rounded-xl overflow-hidden border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
<div className="aspect-[16/9] w-full overflow-hidden bg-slate-200 dark:bg-slate-700 relative">
{currentHero.type === 'video' ? (
<video
src={currentHero.url}
controls
className="w-full h-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={currentHero.url} alt="Hero" className="w-full h-full object-cover" />
)}
<div className="absolute top-2 right-2 bg-black/70 backdrop-blur-md text-white text-xs px-2 py-1 rounded flex items-center space-x-1">
{currentHero.type === 'video' ? <Video className="w-3 h-3" /> : <ImageIcon className="w-3 h-3" />}
<span>{currentHero.type === 'video' ? 'Video' : 'Görsel'}</span>
</div>
</div>
</div>
) : (
<div className="py-12 text-center text-slate-500 bg-slate-50 dark:bg-slate-800 rounded-xl border border-dashed border-slate-300 dark:border-slate-700">
Henüz medya yüklenmemiş. Varsayılan resim gösteriliyor.
</div>
)}
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { getSession } from '@/lib/auth';
import { Sidebar } from './components/Sidebar';
import '../globals.css';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
return (
<html lang="tr">
<body>
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-50 flex">
{session && <Sidebar />}
<main className="flex-1 overflow-x-hidden p-6 md:p-8">
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
</div>
</body>
</html>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { login } from '../actions';
import { Lock } from 'lucide-react';
export default function LoginPage() {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const res = await login(formData);
if (res?.error) {
setError(res.error);
setLoading(false);
} else {
router.push('/admin');
router.refresh(); // Refresh to update layout sidebar
}
};
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="w-full max-w-md bg-white dark:bg-slate-900 shadow-xl rounded-2xl p-8 border border-slate-200 dark:border-slate-800">
<div className="flex justify-center mb-6">
<div className="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-full text-blue-600 dark:text-blue-400">
<Lock className="w-8 h-8" />
</div>
</div>
<h1 className="text-2xl font-bold text-center text-slate-800 dark:text-white mb-8">
Admin Girişi
</h1>
{error && (
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm text-center mb-6 border border-red-100 dark:border-red-800">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Kullanıcı Adı
</label>
<input
type="text"
name="username"
required
className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-slate-900 dark:text-white transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Şifre
</label>
<input
type="password"
name="password"
required
className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-slate-900 dark:text-white transition-all"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Giriş Yapılıyor...' : 'Giriş Yap'}
</button>
</form>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { ImageIcon, Briefcase, Mail } from 'lucide-react';
import Link from 'next/link';
export default async function AdminDashboard() {
const session = await getSession();
if (!session) {
redirect('/admin/login');
}
const galleryCount = await prisma.gallery.count();
const serviceCount = await prisma.service.count();
const unreadMessages = await prisma.contactMessage.count({ where: { isRead: false } });
return (
<div>
<h1 className="text-3xl font-bold mb-8">Dashboard Özeti</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link href="/admin/gallery" className="block">
<div className="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 hover:border-blue-500 dark:hover:border-blue-500 transition-colors">
<div className="flex items-center space-x-4">
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-xl">
<ImageIcon className="w-8 h-8" />
</div>
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Galeri Resimleri</p>
<p className="text-2xl font-bold">{galleryCount}</p>
</div>
</div>
</div>
</Link>
<Link href="/admin/services" className="block">
<div className="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 hover:border-purple-500 dark:hover:border-purple-500 transition-colors">
<div className="flex items-center space-x-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-xl">
<Briefcase className="w-8 h-8" />
</div>
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Hizmetlerimiz</p>
<p className="text-2xl font-bold">{serviceCount}</p>
</div>
</div>
</div>
</Link>
<Link href="/admin/contact" className="block">
<div className="bg-white dark:bg-slate-900 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 hover:border-green-500 dark:hover:border-green-500 transition-colors">
<div className="flex items-center space-x-4">
<div className="p-3 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-xl">
<Mail className="w-8 h-8" />
</div>
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Okunmamış Mesaj</p>
<p className="text-2xl font-bold">{unreadMessages}</p>
</div>
</div>
</div>
</Link>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { Trash2, Plus, Briefcase, Edit, X } from 'lucide-react';
import { addServiceItem, deleteServiceItem, updateServiceItem } from '../actions';
import { revalidatePath } from 'next/cache';
import { uploadImage } from '@/lib/cloudinary';
import Link from 'next/link';
export default async function ServicesAdminPage({ searchParams }: { searchParams: Promise<{ edit?: string }> }) {
const session = await getSession();
if (!session) redirect('/admin/login');
const { edit } = await searchParams;
const services = await prisma.service.findMany({
orderBy: { createdAt: 'desc' }
});
const editingService = edit ? services.find(s => s.id === edit) : null;
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Hizmetlerimiz Yönetimi</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Ekleme / Düzenleme Formu */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6 sticky top-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">{editingService ? "Hizmeti Düzenle" : "Yeni Hizmet Ekle"}</h2>
{editingService && (
<Link href="/admin/services" className="text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</Link>
)}
</div>
<form action={async (formData: FormData) => {
'use server';
const id = formData.get('id') as string;
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const iconFile = formData.get('icon') as File;
if (title && description) {
let iconUrl = editingService?.iconUrl || '';
if (iconFile && iconFile.size > 0) {
iconUrl = await uploadImage(iconFile);
}
if (id) {
await updateServiceItem(id, { title, description, iconUrl });
} else {
await addServiceItem({ title, description, iconUrl });
}
revalidatePath('/admin/services');
redirect('/admin/services');
}
}} className="space-y-4">
{editingService && <input type="hidden" name="id" value={editingService.id} />}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Başlık</label>
<input type="text" name="title" defaultValue={editingService?.title || ""} required className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Açıklama</label>
<textarea name="description" defaultValue={editingService?.description || ""} required rows={4} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Resim Yükle {editingService ? "(Değiştirmek için seçin)" : ""}</label>
<input type="file" name="icon" accept="image/*" required={!editingService} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100" />
{editingService?.iconUrl && (
<div className="mt-2 text-xs text-slate-500">Mevcut resim korunuyor. Yeni resim seçerseniz değiştirilecektir.</div>
)}
</div>
<button type="submit" className="w-full flex items-center justify-center space-x-2 bg-purple-600 hover:bg-purple-700 text-white py-2.5 rounded-lg transition-colors">
{editingService ? <Edit className="w-5 h-5" /> : <Plus className="w-5 h-5" />}
<span>{editingService ? "Güncelle" : "Ekle"}</span>
</button>
</form>
</div>
</div>
{/* Listeleme */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6">
<h2 className="text-xl font-bold mb-6">Mevcut Hizmetler ({services.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{services.map(item => (
<div key={item.id} className="group relative rounded-xl overflow-hidden border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
<div className="aspect-video w-full overflow-hidden bg-slate-200 dark:bg-slate-700">
{item.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.iconUrl} alt={item.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Briefcase className="w-8 h-8 text-slate-400" />
</div>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-slate-800 dark:text-white text-lg">{item.title}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 whitespace-pre-wrap line-clamp-3">{item.description}</p>
</div>
<div className="absolute top-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/admin/services?edit=${item.id}`} className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-lg transition-colors">
<Edit className="w-4 h-4" />
</Link>
<form action={async () => {
'use server';
await deleteServiceItem(item.id);
revalidatePath('/admin/services');
}}>
<button type="submit" className="p-2 bg-red-600 hover:bg-red-700 text-white rounded-lg shadow-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</form>
</div>
</div>
))}
{services.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-500">
Henüz hizmet eklenmemiş.
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { prisma } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { Save, Upload } from 'lucide-react';
import { updateSiteSettings } from '../actions';
import { revalidatePath } from 'next/cache';
import { uploadImage } from '@/lib/cloudinary';
export default async function SettingsAdminPage() {
const session = await getSession();
if (!session) redirect('/admin/login');
const settings = await prisma.siteSettings.findFirst();
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Site Ayarları</h1>
</div>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-6">
<form action={async (formData: FormData) => {
'use server';
const data: any = {
phone: formData.get('phone') as string,
email: formData.get('email') as string,
addressText1: formData.get('addressText1') as string,
addressText2: formData.get('addressText2') as string,
workingHours: formData.get('workingHours') as string,
instagramUrl: formData.get('instagramUrl') as string,
facebookUrl: formData.get('facebookUrl') as string,
youtubeUrl: formData.get('youtubeUrl') as string,
whatsappNumber: formData.get('whatsappNumber') as string,
};
const logoFile = formData.get('logo') as File;
if (logoFile && logoFile.size > 0) {
data.logoUrl = await uploadImage(logoFile);
}
await updateSiteSettings(data);
revalidatePath('/', 'layout');
redirect('/admin/settings');
}} className="space-y-8">
{/* Logo Section */}
<section>
<h2 className="text-xl font-bold mb-4 border-b pb-2">Logo Ayarları</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Yeni Logo Yükle
</label>
<input
type="file"
name="logo"
accept="image/*"
className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
</div>
{settings?.logoUrl && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mevcut Logo</label>
<div className="p-4 bg-slate-800 rounded-lg w-fit">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={settings.logoUrl} alt="Logo" className="h-12 w-auto object-contain" />
</div>
</div>
)}
</div>
</section>
{/* Contact Section */}
<section>
<h2 className="text-xl font-bold mb-4 border-b pb-2">İletişim Bilgileri</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Telefon</label>
<input type="text" name="phone" defaultValue={settings?.phone || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="+90 534 465 62 35" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">E-posta</label>
<input type="email" name="email" defaultValue={settings?.email || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="info@moybeachakyaka.com" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Çalışma Saatleri</label>
<input type="text" name="workingHours" defaultValue={settings?.workingHours || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="Her Gün: 09:00 - 02:00" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">WhatsApp Numarası</label>
<input type="text" name="whatsappNumber" defaultValue={settings?.whatsappNumber || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="905344656235 (Sadece rakam, + olmadan)" />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adres Satırı 1</label>
<input type="text" name="addressText1" defaultValue={settings?.addressText1 || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="Akyaka Mah. Gümüş Sok." />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adres Satırı 2</label>
<input type="text" name="addressText2" defaultValue={settings?.addressText2 || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ula, Muğla / Türkiye" />
</div>
</div>
</section>
{/* Social Media */}
<section>
<h2 className="text-xl font-bold mb-4 border-b pb-2">Sosyal Medya Linkleri</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Instagram URL</label>
<input type="url" name="instagramUrl" defaultValue={settings?.instagramUrl || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="https://instagram.com/moybeachakyaka" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Facebook URL</label>
<input type="url" name="facebookUrl" defaultValue={settings?.facebookUrl || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="https://facebook.com/moybeachakyaka" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">YouTube URL</label>
<input type="url" name="youtubeUrl" defaultValue={settings?.youtubeUrl || ""} className="w-full px-4 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-blue-500" placeholder="https://youtube.com/@moybeach" />
</div>
</div>
</section>
<div className="pt-4 flex justify-end">
<button type="submit" className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-lg transition-colors font-medium">
<Save className="w-5 h-5" />
<span>Ayarları Kaydet</span>
</button>
</div>
</form>
</div>
</div>
);
}