From 7b4b3a85af8cc837450491a84fd8cfec949b670b Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Wed, 3 Jun 2026 04:11:30 +0300 Subject: [PATCH] first commit --- .gitignore | 2 + README.md | 52 +- app/[lang]/page.tsx | 3 +- app/actions/auth.ts | 33 + app/actions/menu.ts | 22 + app/actions/upload.ts | 34 + app/admin/layout.tsx | 25 + app/admin/login/page.tsx | 61 + app/admin/page.tsx | 10 + components/CategorySection.tsx | 104 +- components/MenuItemRow.tsx | 60 +- components/admin/AdminDashboard.tsx | 356 ++++++ data/menu.json | 1628 +++++++++++++++++++++++++++ data/menu.ts | 398 ++----- lib/prisma.ts | 15 + package-lock.json | 949 ++++++++++++++++ package.json | 4 + prisma/schema.prisma | 32 + proxy.ts | 22 + scripts/migrate.ts | 78 ++ 20 files changed, 3446 insertions(+), 442 deletions(-) create mode 100644 app/actions/auth.ts create mode 100644 app/actions/menu.ts create mode 100644 app/actions/upload.ts 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 components/admin/AdminDashboard.tsx create mode 100644 data/menu.json create mode 100644 lib/prisma.ts create mode 100644 prisma/schema.prisma create mode 100644 scripts/migrate.ts diff --git a/.gitignore b/.gitignore index b7ac666..51e763f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ next-env.d.ts # brainstorming sessions .superpowers/ + +/app/generated/prisma diff --git a/README.md b/README.md index e215bc4..f9caa64 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,34 @@ -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). +# Kozmos QR Menu -## Getting Started +Modern, minimalist ve çok dilli (TR/EN) QR Menü Uygulaması. -First, run the development server: +## Özellikler -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +- **Çift Dil Desteği (i18n):** Türkçe ve İngilizce menü seçenekleri +- **Admin Paneli:** Ürün, kategori ekleme/silme ve sıralama (Sürükle-bırak benzeri butonlar ile) +- **Minimalist UI Tasarımı:** Kullanıcı dostu, modern arayüz +- **PostgreSQL & Prisma:** Güvenli ve performanslı veri yönetimi +- **Resim Yükleme:** Admin paneli üzerinden ürünlere görsel ekleyebilme +- **Responsive:** Tüm mobil cihazlar ile tam uyumlu görünüm -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Kurulum -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +1. Depoyu klonlayın ve bağımlılıkları yükleyin: + ```bash + npm install + ``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +2. `.env` ve `.env.local` dosyalarınızı ayarlayın (Veritabanı bağlantısı vb.). -## Learn More +3. Veritabanını güncelleyin ve istemciyi oluşturun: + ```bash + npx prisma db push + npx prisma generate + ``` -To learn more about Next.js, take a look at the following resources: +4. Geliştirme sunucusunu başlatın: + ```bash + npm run dev + ``` -- [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! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Uygulamaya `http://localhost:3000` adresinden ulaşabilirsiniz. diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index 70ae7db..1cd37cc 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from 'next/navigation' import { getDictionary, hasLocale } from './dictionaries' -import { categories } from '@/data/menu' +import { getCategories } from '@/data/menu' import Header from '@/components/Header' import CategorySection from '@/components/CategorySection' import BottomTabBar from '@/components/BottomTabBar' @@ -15,6 +15,7 @@ export default async function MenuPage({ if (!hasLocale(lang)) notFound() const dict = await getDictionary(lang) + const categories = await getCategories() return ( <> diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..052764d --- /dev/null +++ b/app/actions/auth.ts @@ -0,0 +1,33 @@ +'use server' + +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' + +export async function login(prevState: any, formData: FormData) { + const username = formData.get('username') + const password = formData.get('password') + + if ( + username === process.env.ADMIN_USERNAME && + password === process.env.ADMIN_PASSWORD + ) { + const cookieStore = await cookies() + cookieStore.set('admin_session', process.env.ADMIN_SECRET || 'secret', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7 // 1 week + }) + + redirect('/admin') + } else { + return { error: 'Geçersiz kullanıcı adı veya şifre' } + } +} + +export async function logout() { + const cookieStore = await cookies() + cookieStore.delete('admin_session') + redirect('/admin/login') +} diff --git a/app/actions/menu.ts b/app/actions/menu.ts new file mode 100644 index 0000000..fb6a318 --- /dev/null +++ b/app/actions/menu.ts @@ -0,0 +1,22 @@ +'use server' + +import { saveCategories, type Category } from '@/data/menu' +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' + +async function checkAuth() { + const cookieStore = await cookies() + const session = cookieStore.get('admin_session')?.value + if (!session || session !== (process.env.ADMIN_SECRET || 'secret')) { + throw new Error('Unauthorized') + } +} + +export async function saveEntireMenu(categories: Category[]) { + await checkAuth() + await saveCategories(categories) + // Revalidate both language paths + revalidatePath('/tr') + revalidatePath('/en') + return { success: true } +} diff --git a/app/actions/upload.ts b/app/actions/upload.ts new file mode 100644 index 0000000..3ae9993 --- /dev/null +++ b/app/actions/upload.ts @@ -0,0 +1,34 @@ +'use server' + +import { promises as fs } from 'fs' +import path from 'path' +import { cookies } from 'next/headers' + +export async function uploadImage(formData: FormData) { + const cookieStore = await cookies() + const session = cookieStore.get('admin_session')?.value + const secret = process.env.ADMIN_SECRET || 'secret' + + if (!session || session !== secret) { + throw new Error('Unauthorized') + } + + const file = formData.get('file') as File + if (!file) { + throw new Error('No file uploaded') + } + + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + + const extension = path.extname(file.name) || '.jpg' + const filename = `${Date.now()}-${Math.random().toString(36).substring(7)}${extension}` + + const uploadDir = path.join(process.cwd(), 'public', 'uploads') + await fs.mkdir(uploadDir, { recursive: true }) + + const filePath = path.join(uploadDir, filename) + await fs.writeFile(filePath, buffer) + + return { url: `/uploads/${filename}` } +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..9df5483 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react' +import { logout } from '@/app/actions/auth' + +export default function AdminLayout({ children }: { children: ReactNode }) { + return ( +
+
+

Kozmos Yönetim

+ +
+ +
+
+ +
+ {children} +
+
+ ) +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..54c5dbf --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useActionState } from 'react' +import { login } from '@/app/actions/auth' + +export default function LoginPage() { + const [state, formAction, isPending] = useActionState(login, null) + + return ( +
+
+
+

Kozmos Admin

+

Yönetim paneline giriş yapın

+
+ +
+ {state?.error && ( +
+ {state.error} +
+ )} + +
+ + +
+ +
+ + +
+ + +
+
+
+ ) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..f455e0c --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,10 @@ +import { getCategories } from '@/data/menu' +import AdminDashboard from '@/components/admin/AdminDashboard' + +export const dynamic = 'force-dynamic' + +export default async function AdminPage() { + const categories = await getCategories() + + return +} diff --git a/components/CategorySection.tsx b/components/CategorySection.tsx index 4dd7d8b..8bef4f1 100644 --- a/components/CategorySection.tsx +++ b/components/CategorySection.tsx @@ -11,25 +11,17 @@ export default function CategorySection({ category, lang, currency }: Props) { const l = lang as 'tr' | 'en' return ( -
+
{/* Category header */} -
-
- {/* Emoji badge with gradient ring */} +
+
+ {/* Subtle minimal Emoji badge */}
- {/* Outer glow ring */}
-
{category.emoji} @@ -37,75 +29,43 @@ export default function CategorySection({ category, lang, currency }: Props) {
{/* Title */} -
+

{category.label[l]}

+ {/* Item count badge */} + + ({category.items.length}) +
- - {/* Item count badge */} - - {category.items.length} -
- {/* Decorative divider */} -
-
-
-
-
-
- - {/* Items card */} -
- {/* Thin top gradient line */} + {/* Minimalist divider */}
+
-
- {category.items.map((item, i) => ( - - ))} -
+ {/* Items list (no background card) */} +
+ {category.items.map((item, i) => ( + + ))}
) diff --git a/components/MenuItemRow.tsx b/components/MenuItemRow.tsx index be5cbdc..25506da 100644 --- a/components/MenuItemRow.tsx +++ b/components/MenuItemRow.tsx @@ -13,48 +13,56 @@ export default function MenuItemRow({ item, lang, currency, index, isLast }: Pro return (
- {/* Emoji circle with gradient */} + {/* Image or Emoji circle */}
- {item.emoji || '✨'} + {item.image ? ( + {item.name[l]} + ) : ( + item.emoji || '✨' + )}
- {/* Text */} -
-

- {item.name[l]} -

+ {/* Text Content */} +
+
+

+ {item.name[l]} +

+ + {/* Price */} + {item.price !== undefined && ( + + {item.price} {currency} + + )} +
+ {item.description && (

{item.description[l]}

)}
- - {/* Price pill */} - {item.price !== undefined && ( - - {currency}{item.price} - - )}
) } diff --git a/components/admin/AdminDashboard.tsx b/components/admin/AdminDashboard.tsx new file mode 100644 index 0000000..c03c12d --- /dev/null +++ b/components/admin/AdminDashboard.tsx @@ -0,0 +1,356 @@ +'use client' + +import { useState, useTransition, useEffect } from 'react' +import type { Category, MenuItem } from '@/data/menu' +import { saveEntireMenu } from '@/app/actions/menu' +import { uploadImage } from '@/app/actions/upload' + +export default function AdminDashboard({ initialCategories }: { initialCategories: Category[] }) { + const [categories, setCategories] = useState(initialCategories) + const [isPending, startTransition] = useTransition() + const [isSaved, setIsSaved] = useState(false) + const [selectedCatId, setSelectedCatId] = useState(initialCategories[0]?.id || null) + + // Auto-select first category if selected one is deleted + useEffect(() => { + if (selectedCatId && !categories.find(c => c.id === selectedCatId) && categories.length > 0) { + setSelectedCatId(categories[0].id) + } + }, [categories, selectedCatId]) + + const handleSave = () => { + startTransition(async () => { + await saveEntireMenu(categories) + setIsSaved(true) + setTimeout(() => setIsSaved(false), 3000) + }) + } + + const handleImageUpload = async (catId: string, itemId: string, e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const formData = new FormData() + formData.append('file', file) + try { + const res = await uploadImage(formData) + if (res?.url) { + updateItem(catId, itemId, 'image', res.url) + } + } catch (err) { + console.error(err) + alert("Resim yüklenemedi.") + } + } + + // --- Item CRUD --- + const updateItem = (catId: string, itemId: string, field: string, value: any, lang?: 'tr' | 'en') => { + setCategories(cats => cats.map(cat => { + if (cat.id !== catId) return cat + return { + ...cat, + items: cat.items.map(item => { + if (item.id !== itemId) return item + if (lang) { + return { ...item, [field]: { ...(item as any)[field], [lang]: value } } + } + return { ...item, [field]: value } + }) + } + })) + } + + const addItem = (catId: string) => { + const newItem: MenuItem = { + id: `item-${Date.now()}`, + name: { tr: 'Yeni Ürün', en: 'New Item' }, + description: { tr: '', en: '' }, + price: 0, + highlight: false, + } + setCategories(cats => cats.map(cat => { + if (cat.id !== catId) return cat + return { ...cat, items: [...cat.items, newItem] } + })) + } + + const deleteItem = (catId: string, itemId: string) => { + if (!confirm('Bu ürünü silmek istediğinize emin misiniz?')) return + setCategories(cats => cats.map(cat => { + if (cat.id !== catId) return cat + return { ...cat, items: cat.items.filter(item => item.id !== itemId) } + })) + } + + const moveItem = (catId: string, itemIndex: number, direction: 'up' | 'down') => { + setCategories(cats => cats.map(cat => { + if (cat.id !== catId) return cat + const newItems = [...cat.items] + if (direction === 'up' && itemIndex > 0) { + [newItems[itemIndex - 1], newItems[itemIndex]] = [newItems[itemIndex], newItems[itemIndex - 1]] + } else if (direction === 'down' && itemIndex < newItems.length - 1) { + [newItems[itemIndex + 1], newItems[itemIndex]] = [newItems[itemIndex], newItems[itemIndex + 1]] + } + return { ...cat, items: newItems } + })) + } + + // --- Category CRUD --- + const updateCategory = (catId: string, field: string, value: any, lang?: 'tr' | 'en') => { + setCategories(cats => cats.map(cat => { + if (cat.id !== catId) return cat + if (lang && field === 'label') { + return { ...cat, label: { ...cat.label, [lang]: value } } + } + return { ...cat, [field]: value } + })) + } + + const addCategory = () => { + const newCategory: Category = { + id: `cat-${Date.now()}`, + emoji: '🍽️', + label: { tr: 'Yeni Kategori', en: 'New Category' }, + items: [], + } + setCategories(cats => [...cats, newCategory]) + setSelectedCatId(newCategory.id) + } + + const deleteCategory = (catId: string) => { + if (!confirm('Bu kategoriyi ve içindeki tüm ürünleri silmek istediğinize emin misiniz?')) return + setCategories(cats => cats.filter(cat => cat.id !== catId)) + } + + const moveCategory = (catIndex: number, direction: 'up' | 'down') => { + setCategories(cats => { + const newCats = [...cats] + if (direction === 'up' && catIndex > 0) { + [newCats[catIndex - 1], newCats[catIndex]] = [newCats[catIndex], newCats[catIndex - 1]] + } else if (direction === 'down' && catIndex < newCats.length - 1) { + [newCats[catIndex + 1], newCats[catIndex]] = [newCats[catIndex], newCats[catIndex + 1]] + } + return newCats + }) + } + + const selectedCategory = categories.find(c => c.id === selectedCatId) + + return ( +
+ {/* Top Bar */} +
+

Menü Yönetimi

+
+ +
+
+ +
+ {/* Left Sidebar (Categories) */} +
+
+ +
+
+ {categories.map((cat, catIndex) => ( +
setSelectedCatId(cat.id)} + > +
+ {cat.emoji} + + {cat.label.tr} + +
+ + {/* Category Actions (visible on hover or when selected) */} +
+ + + +
+
+ ))} + {categories.length === 0 && ( +
Kategori bulunamadı.
+ )} +
+
+ + {/* Right Content (Items) */} +
+ {selectedCategory ? ( +
+ {/* Category Edit Header */} +
+

Kategori Ayarları

+
+
+ updateCategory(selectedCategory.id, 'emoji', e.target.value)} + className="w-14 h-14 text-center text-3xl border border-gray-300 rounded-xl bg-gray-50" + title="Kategori Emojisi" + /> + Emoji +
+
+
+ + updateCategory(selectedCategory.id, 'label', e.target.value, 'tr')} + className="w-full px-3 py-2 border rounded-md font-bold text-coffee bg-gray-50" + /> +
+
+ + updateCategory(selectedCategory.id, 'label', e.target.value, 'en')} + className="w-full px-3 py-2 border rounded-md text-gray-600 bg-gray-50" + /> +
+
+
+
+ + {/* Items List */} +
+
+

Ürünler ({selectedCategory.items.length})

+
+ + {selectedCategory.items.map((item, itemIndex) => ( +
+ {/* Item Actions (Top Right) */} +
+ + + +
+ +
+ {/* TR Fields */} +
+
Türkçe (TR)
+
+ + updateItem(selectedCategory.id, item.id, 'name', e.target.value, 'tr')} + className="w-full text-sm px-3 py-2 border rounded-md bg-gray-50 focus:bg-white" + /> +
+
+ +