feat: admin panel CRUD, Cloudinary upload, logo, user management
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState, useRef } from 'react'
|
||||
import { saveSettings } from './actions'
|
||||
import Image from 'next/image'
|
||||
import { Upload, X, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function SettingsForm({
|
||||
defaultRestaurantName,
|
||||
defaultLocation,
|
||||
defaultLogoUrl,
|
||||
}: {
|
||||
defaultRestaurantName: string
|
||||
defaultLocation: string
|
||||
defaultLogoUrl: string
|
||||
}) {
|
||||
const [state, action, isPending] = useActionState(saveSettings, undefined)
|
||||
const [logoUrl, setLogoUrl] = useState(defaultLogoUrl)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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')
|
||||
setLogoUrl(data.url)
|
||||
} catch (err: any) {
|
||||
setUploadError(err.message || 'Logo yüklenirken hata oluştu.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-6">
|
||||
{state?.success && (
|
||||
<div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm">
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
{state?.error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Site Logosu
|
||||
</label>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Preview */}
|
||||
<div className="relative w-40 h-20 rounded-xl overflow-hidden border-2 border-gray-200 bg-stone-900 flex items-center justify-center flex-shrink-0">
|
||||
{logoUrl ? (
|
||||
<>
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt="Logo önizleme"
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLogoUrl(''); if (fileInputRef.current) fileInputRef.current.value = '' }}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 text-center px-2">Logo yok<br />(varsayılan kullanılır)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload button */}
|
||||
<div className="flex flex-col gap-2 justify-center h-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? <><Loader2 size={16} className="animate-spin" /> Yükleniyor...</>
|
||||
: <><Upload size={16} /> Logo Yükle</>
|
||||
}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">PNG, SVG, WEBP — Maks. 5MB</p>
|
||||
{uploadError && <p className="text-xs text-red-600">{uploadError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input type="hidden" name="logo_url" value={logoUrl} />
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-100" />
|
||||
|
||||
{/* Restaurant Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Restoran / Mekan Adı
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="restaurant_name"
|
||||
defaultValue={defaultRestaurantName}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Konum Bilgisi
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
defaultValue={defaultLocation}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
|
||||
>
|
||||
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function saveSettings(_prevState: unknown, formData: FormData) {
|
||||
const restaurant_name = formData.get('restaurant_name') as string
|
||||
const location = formData.get('location') as string
|
||||
const logo_url = formData.get('logo_url') as string
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.settings.upsert({
|
||||
where: { key: 'restaurant_name' },
|
||||
update: { value: restaurant_name },
|
||||
create: { key: 'restaurant_name', value: restaurant_name },
|
||||
}),
|
||||
prisma.settings.upsert({
|
||||
where: { key: 'location' },
|
||||
update: { value: location },
|
||||
create: { key: 'location', value: location },
|
||||
}),
|
||||
prisma.settings.upsert({
|
||||
where: { key: 'logo_url' },
|
||||
update: { value: logo_url || '' },
|
||||
create: { key: 'logo_url', value: logo_url || '' },
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/admin/settings')
|
||||
|
||||
return { success: true, message: 'Ayarlar başarıyla kaydedildi.' }
|
||||
} catch (error) {
|
||||
console.error('Settings save error:', error)
|
||||
return { error: 'Ayarlar kaydedilirken bir hata oluştu.' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import SettingsForm from './SettingsForm'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Genel Ayarlar - Admin',
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const settingsData = await prisma.settings.findMany()
|
||||
const settings = settingsData.reduce((acc, curr) => {
|
||||
acc[curr.key] = curr.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const restaurantName = settings['restaurant_name'] || 'Moy Beach'
|
||||
const location = settings['location'] || 'Akyaka · Muğla'
|
||||
const logoUrl = settings['logo_url'] || ''
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Genel Ayarlar</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
|
||||
<SettingsForm
|
||||
defaultRestaurantName={restaurantName}
|
||||
defaultLocation={location}
|
||||
defaultLogoUrl={logoUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user