From 5e4e2f0db909b68e11d3253b1ae3295bac8120ba Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Fri, 5 Jun 2026 17:38:01 +0300 Subject: [PATCH] feat: admin panel CRUD, Cloudinary upload, logo, user management --- .gitignore | 2 + Dockerfile | 4 + app/MenuClient.tsx | 266 ++++ .../(dashboard)/categories/CategoryForm.tsx | 86 ++ .../(dashboard)/categories/DeleteButton.tsx | 23 + .../(dashboard)/categories/[id]/edit/page.tsx | 34 + app/admin/(dashboard)/categories/actions.ts | 90 ++ app/admin/(dashboard)/categories/new/page.tsx | 17 + app/admin/(dashboard)/categories/page.tsx | 81 + app/admin/(dashboard)/layout.tsx | 62 + app/admin/(dashboard)/page.tsx | 72 + .../(dashboard)/products/DeleteButton.tsx | 20 + .../(dashboard)/products/ProductForm.tsx | 210 +++ .../(dashboard)/products/[id]/edit/page.tsx | 46 + app/admin/(dashboard)/products/actions.ts | 92 ++ app/admin/(dashboard)/products/new/page.tsx | 23 + app/admin/(dashboard)/products/page.tsx | 100 ++ .../(dashboard)/settings/SettingsForm.tsx | 158 ++ app/admin/(dashboard)/settings/actions.ts | 38 + app/admin/(dashboard)/settings/page.tsx | 32 + .../(dashboard)/users/CreateUserForm.tsx | 74 + app/admin/(dashboard)/users/UserList.tsx | 135 ++ app/admin/(dashboard)/users/actions.ts | 71 + app/admin/(dashboard)/users/page.tsx | 27 + app/admin/login/LoginForm.tsx | 52 + app/admin/login/actions.ts | 40 + app/admin/login/page.tsx | 26 + app/api/upload/route.ts | 32 + app/page.tsx | 314 +--- lib/auth.ts | 79 + lib/cloudinary.ts | 9 + lib/prisma.ts | 19 + next.config.ts | 12 + package-lock.json | 1364 ++++++++++++++++- package.json | 10 + prisma.config.ts | 14 + prisma/schema.prisma | 53 + proxy.ts | 38 + public/default-product.png | Bin 0 -> 201432 bytes public/logo.png | Bin 0 -> 64720 bytes scripts/admin-seed.ts | 39 + sql/categories.sql | 89 ++ sql/products.sql | 245 +++ 43 files changed, 3917 insertions(+), 281 deletions(-) create mode 100644 app/MenuClient.tsx create mode 100644 app/admin/(dashboard)/categories/CategoryForm.tsx create mode 100644 app/admin/(dashboard)/categories/DeleteButton.tsx create mode 100644 app/admin/(dashboard)/categories/[id]/edit/page.tsx create mode 100644 app/admin/(dashboard)/categories/actions.ts create mode 100644 app/admin/(dashboard)/categories/new/page.tsx create mode 100644 app/admin/(dashboard)/categories/page.tsx create mode 100644 app/admin/(dashboard)/layout.tsx create mode 100644 app/admin/(dashboard)/page.tsx create mode 100644 app/admin/(dashboard)/products/DeleteButton.tsx create mode 100644 app/admin/(dashboard)/products/ProductForm.tsx create mode 100644 app/admin/(dashboard)/products/[id]/edit/page.tsx create mode 100644 app/admin/(dashboard)/products/actions.ts create mode 100644 app/admin/(dashboard)/products/new/page.tsx create mode 100644 app/admin/(dashboard)/products/page.tsx create mode 100644 app/admin/(dashboard)/settings/SettingsForm.tsx create mode 100644 app/admin/(dashboard)/settings/actions.ts create mode 100644 app/admin/(dashboard)/settings/page.tsx create mode 100644 app/admin/(dashboard)/users/CreateUserForm.tsx create mode 100644 app/admin/(dashboard)/users/UserList.tsx create mode 100644 app/admin/(dashboard)/users/actions.ts create mode 100644 app/admin/(dashboard)/users/page.tsx create mode 100644 app/admin/login/LoginForm.tsx create mode 100644 app/admin/login/actions.ts create mode 100644 app/admin/login/page.tsx create mode 100644 app/api/upload/route.ts create mode 100644 lib/auth.ts create mode 100644 lib/cloudinary.ts create mode 100644 lib/prisma.ts create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 proxy.ts create mode 100644 public/default-product.png create mode 100644 public/logo.png create mode 100644 scripts/admin-seed.ts create mode 100644 sql/categories.sql create mode 100644 sql/products.sql diff --git a/.gitignore b/.gitignore index 5ef6a52..45254b6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/app/generated/prisma diff --git a/Dockerfile b/Dockerfile index 94946cd..65c664c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,10 @@ COPY . . # Environment variables must be present at build time for Next.js ENV NEXT_TELEMETRY_DISABLED=1 +# Generate Prisma client before building +COPY prisma ./prisma +RUN npx prisma generate + RUN npm run build # 4. Runner diff --git a/app/MenuClient.tsx b/app/MenuClient.tsx new file mode 100644 index 0000000..5670663 --- /dev/null +++ b/app/MenuClient.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { MenuCategory, MenuItem as MenuItemType } from "@/data/menu"; +import { CategoryNav } from "@/components/CategoryNav"; +import { MenuItem } from "@/components/MenuItem"; + +export const CATEGORY_ICONS: Record = { + "kahvaltiliklar": "🌅", + "kaseler": "🥗", + "burger-ve-sandvic": "🍔", + "pizzalar": "🍕", + "makarnalar": "🍝", + "baslangiclar": "🧆", + "salatalar": "🥬", + "ana-yemekler": "🍽️", + "tatlilar": "🍮", + "i̇mza-kokteyller": "🍹", + "klasik-kokteyller": "🍸", + "biralar": "🍺", + "shotlar": "🥃", + "sise-ve-kadeh-alkollu": "🥂", + "saraplar": "🍷", + "cerezler": "🥜", + "alkolsuz-i̇cecekler": "🧃", + "sicak-kahveler": "☕", + "soguk-kahveler": "🧊", + "ekstralar": "✨", +}; + +export default function MenuClient({ initialCategories }: { initialCategories: MenuCategory[] }) { + const [activeCategoryId, setActiveCategoryId] = useState( + initialCategories[0]?.id || "" + ); + const [selectedItem, setSelectedItem] = useState(null); + const sectionRefs = useRef<(HTMLElement | null)[]>([]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const hit = entries.find((e) => e.isIntersecting); + if (hit) setActiveCategoryId(hit.target.id); + }, + { root: null, rootMargin: "-100px 0px -62% 0px", threshold: 0 } + ); + + sectionRefs.current.forEach((ref) => ref && observer.observe(ref)); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (selectedItem) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + return () => { + document.body.style.overflow = "unset"; + }; + }, [selectedItem]); + + return ( +
+ + {/* ── Hero ─────────────────────────────────────── */} +
+ {/* Background Image */} +
+ + {/* Dark overlay to make text readable */} +
+ + {/* hero content */} +
+ +

+ Akyaka · Muğla +

+ + {/* Logo */} + Moy Beach + +
+
+ +
+
+ +

+ Akyaka'nın en keyifli menüsü +

+ +
+ + {/* wave transition */} +
+ + + +
+
+ + {/* ── Category Nav ─────────────────────────────── */} + + + {/* ── Menu Sections ────────────────────────────── */} +
+ {initialCategories.map((category, index) => ( + { sectionRefs.current[index] = el; }} + className="pt-10 px-4 max-w-lg mx-auto scroll-mt-[62px]" + initial={{ opacity: 0, y: 14 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, margin: "-50px" }} + transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }} + > + {/* section header */} +
+ + {CATEGORY_ICONS[category.id] ?? "🍽️"} + +

+ {category.title} +

+
+ +
+ {category.items.map((item, idx) => ( + setSelectedItem(item)} /> + ))} +
+
+ ))} +
+ + {/* ── Footer ───────────────────────────────────── */} +
+
+ {/* Footer Logo */} + Moy Beach +

+ Akyaka, Muğla +

+
+
+ +
+
+

+ © 2026 Moy Beach. Tüm hakları saklıdır. +

+
+
+ + {/* ── Modal ────────────────────────────────────── */} + + {selectedItem && ( + setSelectedItem(null)} + > + e.stopPropagation()} + > + {selectedItem.image && ( +
+ {selectedItem.name} + +
+ )} +
+
+

+ {selectedItem.name} +

+ + {selectedItem.price} + +
+

+ {selectedItem.description} +

+
+
+
+ )} +
+
+ ); +} diff --git a/app/admin/(dashboard)/categories/CategoryForm.tsx b/app/admin/(dashboard)/categories/CategoryForm.tsx new file mode 100644 index 0000000..83b75ae --- /dev/null +++ b/app/admin/(dashboard)/categories/CategoryForm.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useActionState } from 'react' +import { createCategory, updateCategory } from './actions' +import Link from 'next/link' + +type Category = { + id: number + name_tr: string + order_num: number + status: number | null +} + +export default function CategoryForm({ category }: { category?: Category }) { + const isEditing = !!category + + // Use appropriate action based on editing state + const actionToUse = isEditing + ? updateCategory.bind(null, category.id) + : createCategory + + const [state, formAction, isPending] = useActionState(actionToUse, undefined) + + return ( +
+ {state?.error && ( +
+ {state.error} +
+ )} + +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + İptal + + +
+
+ ) +} diff --git a/app/admin/(dashboard)/categories/DeleteButton.tsx b/app/admin/(dashboard)/categories/DeleteButton.tsx new file mode 100644 index 0000000..b27f5fe --- /dev/null +++ b/app/admin/(dashboard)/categories/DeleteButton.tsx @@ -0,0 +1,23 @@ +'use client' + +import { Trash2 } from 'lucide-react' +import { deleteCategory } from './actions' + +export default function DeleteButton({ id }: { id: number }) { + return ( + + ) +} diff --git a/app/admin/(dashboard)/categories/[id]/edit/page.tsx b/app/admin/(dashboard)/categories/[id]/edit/page.tsx new file mode 100644 index 0000000..1c6a703 --- /dev/null +++ b/app/admin/(dashboard)/categories/[id]/edit/page.tsx @@ -0,0 +1,34 @@ +import prisma from '@/lib/prisma' +import CategoryForm from '../../CategoryForm' +import { notFound } from 'next/navigation' + +export const metadata = { + title: 'Kategori Düzenle - Admin', +} + +export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) { + const resolvedParams = await params + const id = parseInt(resolvedParams.id, 10) + + if (isNaN(id)) { + notFound() + } + + const category = await prisma.categories.findUnique({ + where: { id } + }) + + if (!category) { + notFound() + } + + return ( +
+

Kategori Düzenle

+ +
+ +
+
+ ) +} diff --git a/app/admin/(dashboard)/categories/actions.ts b/app/admin/(dashboard)/categories/actions.ts new file mode 100644 index 0000000..e9064df --- /dev/null +++ b/app/admin/(dashboard)/categories/actions.ts @@ -0,0 +1,90 @@ +'use server' + +import prisma from '@/lib/prisma' +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function createCategory(_prevState: unknown, formData: FormData) { + const name_tr = formData.get('name_tr') as string + const name = formData.get('name') as string || name_tr + const order_num = parseInt(formData.get('order_num') as string, 10) || 0 + const status = formData.get('status') === 'on' ? 1 : 0 + + if (!name_tr) { + return { error: 'Lütfen kategori adını doldurun.' } + } + + try { + await prisma.categories.create({ + data: { + name, + name_tr, + slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + order_num, + status, + } + }) + } catch (error) { + console.error('Create category error:', error) + return { error: 'Kategori eklenirken hata oluştu.' } + } + + revalidatePath('/admin/categories') + revalidatePath('/') + redirect('/admin/categories') +} + +export async function updateCategory(id: number, _prevState: unknown, formData: FormData) { + const name_tr = formData.get('name_tr') as string + const name = formData.get('name') as string || name_tr + const order_num = parseInt(formData.get('order_num') as string, 10) || 0 + const status = formData.get('status') === 'on' ? 1 : 0 + + if (!name_tr) { + return { error: 'Lütfen kategori adını doldurun.' } + } + + try { + await prisma.categories.update({ + where: { id }, + data: { + name, + name_tr, + slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + order_num, + status, + } + }) + } catch (error) { + console.error('Update category error:', error) + return { error: 'Kategori güncellenirken hata oluştu.' } + } + + revalidatePath('/admin/categories') + revalidatePath('/') + redirect('/admin/categories') +} + +export async function deleteCategory(id: number) { + try { + // Check if category has products + const productsCount = await prisma.products.count({ + where: { category_id: id } + }) + + if (productsCount > 0) { + return { error: 'Bu kategoriye ait ürünler olduğu için silinemez. Önce ürünleri silin veya başka kategoriye taşıyın.' } + } + + await prisma.categories.delete({ + where: { id } + }) + + revalidatePath('/admin/categories') + revalidatePath('/') + return { success: true } + } catch (error) { + console.error('Delete category error:', error) + return { error: 'Silme işlemi başarısız oldu.' } + } +} diff --git a/app/admin/(dashboard)/categories/new/page.tsx b/app/admin/(dashboard)/categories/new/page.tsx new file mode 100644 index 0000000..4227cce --- /dev/null +++ b/app/admin/(dashboard)/categories/new/page.tsx @@ -0,0 +1,17 @@ +import CategoryForm from '../CategoryForm' + +export const metadata = { + title: 'Yeni Kategori Ekle - Admin', +} + +export default function NewCategoryPage() { + return ( +
+

Yeni Kategori Ekle

+ +
+ +
+
+ ) +} diff --git a/app/admin/(dashboard)/categories/page.tsx b/app/admin/(dashboard)/categories/page.tsx new file mode 100644 index 0000000..859995d --- /dev/null +++ b/app/admin/(dashboard)/categories/page.tsx @@ -0,0 +1,81 @@ +import prisma from '@/lib/prisma' +import { Pencil, Plus } from 'lucide-react' +import Link from 'next/link' +import DeleteButton from './DeleteButton' + +export const metadata = { + title: 'Kategoriler - Admin', +} + +export default async function CategoriesPage() { + const categories = await prisma.categories.findMany({ + orderBy: { order_num: 'asc' }, + include: { + _count: { + select: { products: true } + } + } + }) + + return ( +
+
+

Kategoriler

+ + Yeni Kategori Ekle + +
+ +
+
+ + + + + + + + + + + + {categories.map((category) => ( + + + + + + + + ))} + {categories.length === 0 && ( + + + + )} + +
Kategori AdıSıraÜrün SayısıDurumİşlemler
{category.name_tr}{category.order_num} + + {category._count.products} Ürün + + + + {category.status === 1 ? 'Aktif' : 'Pasif'} + + +
+ + + + +
+
+ Henüz kategori bulunmuyor. +
+
+
+
+ ) +} diff --git a/app/admin/(dashboard)/layout.tsx b/app/admin/(dashboard)/layout.tsx new file mode 100644 index 0000000..0e5ff6c --- /dev/null +++ b/app/admin/(dashboard)/layout.tsx @@ -0,0 +1,62 @@ +import Link from 'next/link' +import { LayoutDashboard, LogOut, Package, Settings, Tags, Users2 } from 'lucide-react' +import { logout } from '@/lib/auth' +import { redirect } from 'next/navigation' + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {children} +
+ + {/* Mobile Bottom Nav (Optional, simpler for now just a top bar) */} +
+ ) +} diff --git a/app/admin/(dashboard)/page.tsx b/app/admin/(dashboard)/page.tsx new file mode 100644 index 0000000..d7ce2b4 --- /dev/null +++ b/app/admin/(dashboard)/page.tsx @@ -0,0 +1,72 @@ +import prisma from '@/lib/prisma' +import { Package, Tags, Eye, TrendingUp } from 'lucide-react' +import Link from 'next/link' + +export const metadata = { + title: 'Admin Dashboard - Moy Beach', +} + +export default async function AdminDashboard() { + const [totalCategories, totalProducts, activeProducts] = await Promise.all([ + prisma.categories.count(), + prisma.products.count(), + prisma.products.count({ where: { status: 1 } }), + ]) + + return ( +
+

Dashboard

+ + {/* Stats Cards */} +
+
+
+ +
+
+

Kategori Sayısı

+

{totalCategories}

+
+
+ +
+
+ +
+
+

Toplam Ürün

+

{totalProducts}

+
+
+ +
+
+ +
+
+

Aktif Ürünler

+

{activeProducts}

+
+
+
+ + {/* Quick Actions */} +
+
+

Hızlı Kısayollar

+
+ + Ürünleri Yönet + + + Kategorileri Yönet + + + Canlı Menüyü Görüntüle + +
+
+
+
+ ) +} diff --git a/app/admin/(dashboard)/products/DeleteButton.tsx b/app/admin/(dashboard)/products/DeleteButton.tsx new file mode 100644 index 0000000..4cf977e --- /dev/null +++ b/app/admin/(dashboard)/products/DeleteButton.tsx @@ -0,0 +1,20 @@ +'use client' + +import { Trash2 } from 'lucide-react' +import { deleteProduct } from './actions' + +export default function DeleteButton({ id }: { id: number }) { + return ( + + ) +} diff --git a/app/admin/(dashboard)/products/ProductForm.tsx b/app/admin/(dashboard)/products/ProductForm.tsx new file mode 100644 index 0000000..63eae9c --- /dev/null +++ b/app/admin/(dashboard)/products/ProductForm.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useActionState, useState, useRef } from 'react' +import { createProduct, updateProduct } from './actions' +import Link from 'next/link' +import Image from 'next/image' +import { Upload, X, Loader2 } from 'lucide-react' + +const DEFAULT_IMAGE = '/default-product.png' + +type Category = { + id: number + name_tr: string +} + +type Product = { + id: number + name_tr: string + price: number | string + category_id: number | null + description_tr: string | null + image_url: string | null + status: number | null +} + +export default function ProductForm({ categories, product }: { categories: Category[], product?: Product }) { + const isEditing = !!product + const actionToUse = isEditing + ? updateProduct.bind(null, product.id) + : createProduct + + const [state, formAction, isPending] = useActionState(actionToUse, undefined) + const [imageUrl, setImageUrl] = useState(product?.image_url || '') + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState('') + const fileInputRef = useRef(null) + + const previewSrc = imageUrl || DEFAULT_IMAGE + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + + if (file.size > 5 * 1024 * 1024) { + setUploadError("Dosya boyutu 5MB'ı geçemez.") + return + } + + setUploadError('') + setUploading(true) + + try { + const fd = new FormData() + fd.append('file', file) + const res = await fetch('/api/upload', { method: 'POST', body: fd }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Yükleme başarısız') + setImageUrl(data.url) + } catch (err: any) { + setUploadError(err.message || 'Resim yüklenirken hata oluştu.') + } finally { + setUploading(false) + } + } + + return ( +
+ {state?.error && ( +
+ {state.error} +
+ )} + + {/* Image Upload */} +
+ +
+ {/* Preview */} +
+ Ürün resmi önizleme + {imageUrl && ( + + )} +
+ + {/* Upload button */} +
+ +

PNG, JPG, WEBP — Maks. 5MB

+ {uploadError &&

{uploadError}

} + {!imageUrl && ( +

Resim yüklenmezse varsayılan görsel kullanılır.

+ )} +
+
+ + + {/* URL'yi form verisiyle gönder */} + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +