From 94182b6bc5dd58198885695390a92859516c143f Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Fri, 5 Jun 2026 16:59:05 +0300 Subject: [PATCH] feat: add dynamic site settings, hero media, admin panels, and database integration --- app/[lang]/page.tsx | 26 +- app/admin/actions.ts | 102 +++++ app/admin/components/Sidebar.tsx | 61 +++ app/admin/contact/page.tsx | 89 +++++ app/admin/gallery/page.tsx | 105 +++++ app/admin/hero/page.tsx | 92 +++++ app/admin/layout.tsx | 22 ++ app/admin/login/page.tsx | 82 ++++ app/admin/page.tsx | 66 ++++ app/admin/services/page.tsx | 139 +++++++ app/admin/settings/page.tsx | 134 +++++++ components/Contact.tsx | 16 +- components/Footer.tsx | 27 +- components/Gallery.tsx | 14 +- components/Header.tsx | 6 +- components/Hero.tsx | 31 +- components/Services.tsx | 30 +- lib/auth.ts | 68 ++++ lib/cloudinary.ts | 21 + lib/db.ts | 13 + next.config.ts | 9 + package-lock.json | 642 ++++++++++++++++++++++++++++++- package.json | 6 + prisma/schema.prisma | 65 ++++ middleware.ts => proxy.ts | 6 +- scripts/seed.ts | 27 ++ scripts/upload-to-cloudinary.js | 47 +++ 27 files changed, 1894 insertions(+), 52 deletions(-) create mode 100644 app/admin/actions.ts create mode 100644 app/admin/components/Sidebar.tsx create mode 100644 app/admin/contact/page.tsx create mode 100644 app/admin/gallery/page.tsx create mode 100644 app/admin/hero/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/services/page.tsx create mode 100644 app/admin/settings/page.tsx create mode 100644 lib/auth.ts create mode 100644 lib/cloudinary.ts create mode 100644 lib/db.ts create mode 100644 prisma/schema.prisma rename middleware.ts => proxy.ts (88%) create mode 100644 scripts/seed.ts create mode 100644 scripts/upload-to-cloudinary.js diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index cd639d5..74102c6 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -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 (
-
- +
+ - - + + - -
+ +
); } diff --git a/app/admin/actions.ts b/app/admin/actions.ts new file mode 100644 index 0000000..5e5bf97 --- /dev/null +++ b/app/admin/actions.ts @@ -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 }); + } +} diff --git a/app/admin/components/Sidebar.tsx b/app/admin/components/Sidebar.tsx new file mode 100644 index 0000000..403652f --- /dev/null +++ b/app/admin/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/app/admin/contact/page.tsx b/app/admin/contact/page.tsx new file mode 100644 index 0000000..cd89294 --- /dev/null +++ b/app/admin/contact/page.tsx @@ -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 ( +
+

İletişim Mesajları

+
+
+ + + + + + + + + + + + + + {messages.map(msg => ( + + + + + + + + + + ))} + {messages.length === 0 && ( + + + + )} + +
TarihGönderenE-postaKonuMesajDurumİşlem
{msg.createdAt.toLocaleDateString('tr-TR')}{msg.name}{msg.email}{msg.subject || '-'}{msg.message} + {msg.isRead ? ( + + Okundu + + ) : ( + + Yeni + + )} + +
+ {!msg.isRead && ( +
{ + 'use server'; + await markContactAsRead(msg.id); + revalidatePath('/admin/contact'); + }}> + +
+ )} +
{ + 'use server'; + await deleteContact(msg.id); + revalidatePath('/admin/contact'); + }}> + +
+
+
Hiç mesaj bulunamadı.
+
+
+
+ ); +} diff --git a/app/admin/gallery/page.tsx b/app/admin/gallery/page.tsx new file mode 100644 index 0000000..a83705a --- /dev/null +++ b/app/admin/gallery/page.tsx @@ -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 ( +
+
+

Galeri Yönetimi

+
+ +
+ {/* Ekleme Formu */} +
+
+

Yeni Resim Ekle

+
{ + '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"> +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + {/* Listeleme */} +
+
+

Mevcut Resimler ({galleryItems.length})

+ +
+ {galleryItems.map(item => ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.title} +
+
+

{item.title}

+

{item.category}

+
+
+
{ + 'use server'; + await deleteGalleryItem(item.id); + revalidatePath('/admin/gallery'); + }}> + +
+
+
+ ))} + {galleryItems.length === 0 && ( +
+ Galeriye henüz resim eklenmemiş. +
+ )} +
+
+
+
+
+ ); +} diff --git a/app/admin/hero/page.tsx b/app/admin/hero/page.tsx new file mode 100644 index 0000000..bbc972c --- /dev/null +++ b/app/admin/hero/page.tsx @@ -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 ( +
+
+

Hero (Ana Ekran) Yönetimi

+
+ +
+ {/* Yükleme Formu */} +
+

Yeni Medya Yükle

+
{ + '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"> +
+ + +

+ Yeni bir dosya yüklediğinizde, mevcut olan otomatik olarak silinir ve yerini alır. Sadece bir adet Hero medyası bulunabilir. +

+
+ +
+
+ + {/* Mevcut Medya */} +
+

Aktif Medya

+ {currentHero ? ( +
+
+ {currentHero.type === 'video' ? ( +
+
+ ) : ( +
+ Henüz medya yüklenmemiş. Varsayılan resim gösteriliyor. +
+ )} +
+
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..8a9846b --- /dev/null +++ b/app/admin/layout.tsx @@ -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 ( + + +
+ {session && } +
+
+ {children} +
+
+
+ + + ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..47f538f --- /dev/null +++ b/app/admin/login/page.tsx @@ -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) => { + 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 ( +
+
+
+
+ +
+
+

+ Admin Girişi +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..149dcb6 --- /dev/null +++ b/app/admin/page.tsx @@ -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 ( +
+

Dashboard Özeti

+ +
+ +
+
+
+ +
+
+

Galeri Resimleri

+

{galleryCount}

+
+
+
+ + + +
+
+
+ +
+
+

Hizmetlerimiz

+

{serviceCount}

+
+
+
+ + + +
+
+
+ +
+
+

Okunmamış Mesaj

+

{unreadMessages}

+
+
+
+ +
+
+ ); +} diff --git a/app/admin/services/page.tsx b/app/admin/services/page.tsx new file mode 100644 index 0000000..a7dc199 --- /dev/null +++ b/app/admin/services/page.tsx @@ -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 ( +
+
+

Hizmetlerimiz Yönetimi

+
+ +
+ {/* Ekleme / Düzenleme Formu */} +
+
+
+

{editingService ? "Hizmeti Düzenle" : "Yeni Hizmet Ekle"}

+ {editingService && ( + + + + )} +
+ +
{ + '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 && } + +
+ + +
+
+ + +
+
+ + + {editingService?.iconUrl && ( +
Mevcut resim korunuyor. Yeni resim seçerseniz değiştirilecektir.
+ )} +
+ +
+
+
+ + {/* Listeleme */} +
+
+

Mevcut Hizmetler ({services.length})

+ +
+ {services.map(item => ( +
+
+ {item.iconUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.title} + ) : ( +
+ +
+ )} +
+
+

{item.title}

+

{item.description}

+
+
+ + + +
{ + 'use server'; + await deleteServiceItem(item.id); + revalidatePath('/admin/services'); + }}> + +
+
+
+ ))} + {services.length === 0 && ( +
+ Henüz hizmet eklenmemiş. +
+ )} +
+
+
+
+
+ ); +} diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..f55c1f4 --- /dev/null +++ b/app/admin/settings/page.tsx @@ -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 ( +
+
+

Site Ayarları

+
+ +
+
{ + '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 */} +
+

Logo Ayarları

+
+
+ + +
+ {settings?.logoUrl && ( +
+ +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo +
+
+ )} +
+
+ + {/* Contact Section */} +
+

İletişim Bilgileri

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Social Media */} +
+

Sosyal Medya Linkleri

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/components/Contact.tsx b/components/Contact.tsx index bb3ebf6..1f01114 100644 --- a/components/Contact.tsx +++ b/components/Contact.tsx @@ -2,7 +2,7 @@ import { MapPin, Phone, Mail, Clock } from "lucide-react"; -export default function Contact({ dict }: { dict: any }) { +export default function Contact({ dict, dbSettings }: { dict: any, dbSettings?: any }) { return (
@@ -22,8 +22,8 @@ export default function Contact({ dict }: { dict: any }) {

{dict.contact.address.title}

- {dict.contact.address.text1}
- {dict.contact.address.text2} + {dbSettings?.addressText1 || dict.contact.address.text1}
+ {dbSettings?.addressText2 || dict.contact.address.text2}

@@ -35,7 +35,9 @@ export default function Contact({ dict }: { dict: any }) {

{dict.contact.phone}

- +90 534 465 62 35 + + {dbSettings?.phone || '+90 534 465 62 35'} +

@@ -47,7 +49,9 @@ export default function Contact({ dict }: { dict: any }) {

{dict.contact.email}

- info@moybeachakyaka.com + + {dbSettings?.email || 'info@moybeachakyaka.com'} +

@@ -59,7 +63,7 @@ export default function Contact({ dict }: { dict: any }) {

{dict.contact.hours.title}

- {dict.contact.hours.text} + {dbSettings?.workingHours || dict.contact.hours.text}

diff --git a/components/Footer.tsx b/components/Footer.tsx index a4ac606..bc7bde7 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -33,7 +33,7 @@ const socials = [ }, ]; -export default function Footer({ dict }: { dict: any }) { +export default function Footer({ dict, dbSettings }: { dict: any, dbSettings?: any }) { const links = [ { label: dict.nav.about, href: "#about" }, { label: dict.nav.services, href: "#services" }, @@ -41,6 +41,13 @@ export default function Footer({ dict }: { dict: any }) { { label: dict.nav.contact, href: "#contact" }, ]; + const dynamicSocials = socials.map(s => { + if (s.label === "Instagram" && dbSettings?.instagramUrl) return { ...s, href: dbSettings.instagramUrl }; + if (s.label === "Facebook" && dbSettings?.facebookUrl) return { ...s, href: dbSettings.facebookUrl }; + if (s.label === "YouTube" && dbSettings?.youtubeUrl) return { ...s, href: dbSettings.youtubeUrl }; + return s; + }); + return (