feat: Complete Corporate Site Deployment Checkpoint

This commit is contained in:
2025-12-08 10:25:51 +03:00
parent 2904aa5e03
commit 08f8a55b6c
34 changed files with 2051 additions and 52 deletions

View File

@@ -0,0 +1,37 @@
import { GalleryGrid } from "@/components/public/gallery-grid"
import { createClient } from "@/lib/supabase/server"
import { GalleryItem } from "@/types/cms"
export const revalidate = 0 // Force dynamic behavior to ensure fresh data
export default async function GalleryAllPage() {
const supabase = await createClient()
// Fetch ALL gallery items, ordered by 'order'
const { data: gallery } = await supabase
.from('gallery')
.select('*')
.order('order')
return (
<div className="min-h-screen py-20 bg-background">
<div className="max-w-7xl mx-auto px-4 md:px-8 space-y-12">
<div className="text-center space-y-3">
<h1 className="text-3xl md:text-5xl font-bold tracking-tight">Fotoğraf Galerisi</h1>
<div className="w-24 h-1.5 bg-primary mx-auto rounded-full" />
<p className="text-muted-foreground max-w-2xl mx-auto text-lg">
En özel anlarınıza şahitlik ettiğimiz muhteşem düğünlerden kareler.
</p>
</div>
{(!gallery || gallery.length === 0) ? (
<div className="text-center py-20 text-muted-foreground bg-secondary/30 rounded-3xl">
Henüz galeriye fotoğraf eklenmemiş.
</div>
) : (
<GalleryGrid items={gallery as GalleryItem[]} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { SiteHeader } from "@/components/public/site-header"
import { SiteFooter } from "@/components/public/site-footer"
interface PublicLayoutProps {
children: React.ReactNode
}
import { createClient } from "@/lib/supabase/server"
export default async function PublicLayout({ children }: PublicLayoutProps) {
const supabase = await createClient()
const { data: contents } = await supabase
.from('site_contents')
.select('key, value')
.in('key', ['site_title', 'site_logo', 'contact_phone', 'contact_address', 'contact_email', 'hero_subtitle', 'social_instagram', 'social_facebook', 'social_twitter'])
const siteTitle = contents?.find(c => c.key === 'site_title')?.value || "Rüya Düğün Salonu"
const siteLogo = contents?.find(c => c.key === 'site_logo')?.value
const phone = contents?.find(c => c.key === 'contact_phone')?.value
const address = contents?.find(c => c.key === 'contact_address')?.value
const email = contents?.find(c => c.key === 'contact_email')?.value
const description = contents?.find(c => c.key === 'hero_subtitle')?.value
// Social Media
const instagram = contents?.find(c => c.key === 'social_instagram')?.value
const facebook = contents?.find(c => c.key === 'social_facebook')?.value
const twitter = contents?.find(c => c.key === 'social_twitter')?.value
return (
<div className="flex min-h-screen flex-col">
<SiteHeader siteTitle={siteTitle} siteLogo={siteLogo} />
<main className="flex-1">
{children}
</main>
<SiteFooter
siteTitle={siteTitle}
phone={phone}
address={address}
email={email}
description={description}
socials={{ instagram, facebook, twitter }}
/>
</div>
)
}

186
src/app/(public)/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
import { Button } from "@/components/ui/button"
import { HeroCarousel } from "@/components/public/hero-carousel"
import Link from "next/link"
import { GalleryGrid } from "@/components/public/gallery-grid"
import { createClient } from "@/lib/supabase/server"
import Image from "next/image"
import { Service, GalleryItem, SiteContent } from "@/types/cms"
import { Card, CardContent } from "@/components/ui/card"
import { MapPin, Phone, Mail } from "lucide-react"
async function getPublicData() {
const supabase = await createClient()
const [
{ data: contents },
{ data: services },
{ data: gallery }
] = await Promise.all([
supabase.from('site_contents').select('*'),
supabase.from('services').select('*').eq('is_active', true).order('order'),
supabase.from('gallery').select('*').order('order').limit(8)
])
// Helper to get content by key
const getContent = (key: string) => {
return (contents as SiteContent[])?.find(c => c.key === key)?.value || ''
}
const heroImages = gallery?.filter((item: GalleryItem) => item.is_hero) || []
return {
contents: contents as SiteContent[],
services: services as Service[],
gallery: (gallery as GalleryItem[])?.slice(0, 8),
heroImages: heroImages as GalleryItem[],
getContent
}
}
export default async function LandingPage() {
const { services, gallery, heroImages, getContent } = await getPublicData()
const heroTitle = getContent('hero_title')
const heroSubtitle = getContent('hero_subtitle')
const contactPhone = getContent('contact_phone')
const contactAddress = getContent('contact_address')
const contactEmail = getContent('contact_email')
const mapEmbed = getContent('contact_map_embed')
// Fallback image if not set? No, we use a placeholder or check if content exists.
return (
<div className="flex flex-col min-h-screen">
{/* HER SECTİON */}
<section className="relative h-[600px] flex items-center justify-center bg-slate-900 text-white overflow-hidden">
<HeroCarousel images={heroImages}>
<div className="relative z-20 container text-center space-y-6 animate-in fade-in zoom-in duration-700">
<h1 className="text-3xl md:text-6xl font-bold tracking-tighter drop-shadow-lg">
{heroTitle || 'Hayallerinizdeki Düğün'}
</h1>
<p className="text-lg md:text-xl text-white/90 max-w-[800px] mx-auto drop-shadow-md font-light">
{heroSubtitle || 'Unutulmaz anlar için...'}
</p>
</div>
</HeroCarousel>
</section>
{/* SERVICES SECTION */}
<section id="features" className="py-20 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12 space-y-3">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Hizmetlerimiz</h2>
<div className="w-16 h-1 bg-primary mx-auto rounded-full" />
<p className="text-muted-foreground max-w-2xl mx-auto">
Size özel sunduğumuz ayrıcalıklar ve profesyonel hizmetler.
</p>
</div>
<div className="flex flex-wrap justify-center gap-6">
{services?.map((service) => (
<Card key={service.id} className="group border-none shadow-sm hover:shadow-lg transition-all duration-300 w-full sm:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] xl:w-[calc(30%-16px)]">
<div className="relative h-48 overflow-hidden rounded-t-lg">
{service.image_url ? (
<Image
src={service.image_url}
alt={service.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
/>
) : (
<div className="w-full h-full bg-slate-200 flex items-center justify-center text-slate-400">
Görsel Yok
</div>
)}
</div>
<CardContent className="p-6 text-center space-y-3">
<h3 className="text-xl font-bold group-hover:text-primary transition-colors">
{service.title}
</h3>
<p className="text-muted-foreground text-sm leading-relaxed line-clamp-3">
{service.description}
</p>
</CardContent>
</Card>
))}
</div>
{(!services || services.length === 0) && (
<p className="text-center text-muted-foreground">Henüz hizmet eklenmemiş.</p>
)}
</div>
</section>
{/* GALLERY PREVIEW SECTION */}
<section id="gallery" className="py-20">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12 space-y-3">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Galeri</h2>
<div className="w-16 h-1 bg-primary mx-auto rounded-full" />
<p className="text-muted-foreground">En mutlu anlarınızdan kareler.</p>
</div>
<GalleryGrid items={gallery || []} />
<div className="text-center mt-12">
<Link href="/galeri">
<Button variant="outline" size="lg" className="rounded-full px-8">Tüm Galeriyi Gör</Button>
</Link>
</div>
</div>
</section>
{/* CONTACT SECTION */}
<section id="contact" className="py-20 bg-slate-900 text-white">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<div>
<h2 className="text-2xl md:text-3xl font-bold mb-4">Bizimle İletişime Geçin</h2>
<p className="text-slate-300 leading-relaxed">
Düğününüzü planlamak veya salonumuzu görmek için bizi arayın veya ziyaret edin.
Kahve eşliğinde hayallerinizi konuşalım.
</p>
</div>
<div className="space-y-6">
<div className="flex items-start space-x-4">
<MapPin className="w-6 h-6 text-primary mt-1" />
<div>
<h4 className="font-semibold text-lg">Adres</h4>
<p className="text-slate-400 mt-1">{contactAddress || 'Adres bilgisi girilmemiş.'}</p>
</div>
</div>
<div className="flex items-start space-x-4">
<Phone className="w-6 h-6 text-primary mt-1" />
<div>
<h4 className="font-semibold text-lg">Telefon</h4>
<p className="text-slate-400 mt-1">{contactPhone || 'Telefon bilgisi girilmemiş.'}</p>
</div>
</div>
<div className="flex items-start space-x-4">
<Mail className="w-6 h-6 text-primary mt-1" />
<div>
<h4 className="font-semibold text-lg">E-Posta</h4>
<p className="text-slate-400 mt-1">{contactEmail || 'E-Posta bilgisi girilmemiş.'}</p>
</div>
</div>
</div>
</div>
{/* MAP */}
<div className="h-[400px] w-full rounded-2xl overflow-hidden shadow-2xl bg-slate-800 border border-slate-700 relative">
{mapEmbed ? (
<div
className="w-full h-full [&>iframe]:w-full [&>iframe]:h-full [&>iframe]:border-0"
dangerouslySetInnerHTML={{ __html: mapEmbed }}
/>
) : (
<div className="flex items-center justify-center h-full text-slate-500">
Harita bilgisi eklenmemiş.
</div>
)}
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,34 @@
'use server'
import { createClient } from "@/lib/supabase/server"
import { SiteContent } from "@/types/cms"
import { revalidatePath } from "next/cache"
export async function updateSiteContent(updates: SiteContent[]) {
const supabase = await createClient()
try {
// Upsert all updates
// Note: Supabase upsert accepts an array of objects
const { error } = await supabase
.from('site_contents')
.upsert(
updates.map(({ key, value, type, section }) => ({
key,
value,
type,
section,
updated_at: new Date().toISOString()
}))
)
if (error) throw error
revalidatePath('/dashboard/cms/content')
revalidatePath('/', 'layout') // Revalidate public root layout (header title)
return { success: true }
} catch (error) {
console.error('Content update error:', error)
return { success: false, error: 'İçerik güncellenirken bir hata oluştu.' }
}
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState } from "react"
import { SiteContent } from "@/types/cms"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ImageUpload } from "@/components/ui/image-upload"
import { updateSiteContent } from "./actions"
import { toast } from "sonner"
import { Save, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface ContentFormProps {
initialContent: SiteContent[]
}
const SECTIONS = [
{ id: 'general', label: 'Genel Ayarlar' },
{ id: 'home', label: 'Anasayfa' },
{ id: 'contact', label: 'İletişim' },
]
export function ContentForm({ initialContent }: ContentFormProps) {
const [contents, setContents] = useState<SiteContent[]>(initialContent)
const [loading, setLoading] = useState(false)
const [activeSection, setActiveSection] = useState('general')
const handleChange = (key: string, value: string) => {
setContents(prev => prev.map(item =>
item.key === key ? { ...item, value } : item
))
}
const onSubmit = async () => {
setLoading(true)
try {
const result = await updateSiteContent(contents)
if (result.success) {
toast.success("İçerikler başarıyla güncellendi")
} else {
toast.error(result.error)
}
} catch (error) {
toast.error("Bir hata oluştu")
} finally {
setLoading(false)
}
}
const filteredContent = contents.filter(item => item.section === activeSection)
return (
<div className="space-y-6">
{/* Custom Tabs */}
<div className="flex space-x-1 rounded-lg bg-muted p-1">
{SECTIONS.map((section) => (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={cn(
"flex-1 justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
activeSection === section.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/50 hover:text-foreground"
)}
>
{section.label}
</button>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{SECTIONS.find(s => s.id === activeSection)?.label}</CardTitle>
<CardDescription>
Bu bölümdeki içerikleri aşağıdan düzenleyebilirsiniz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{filteredContent.length === 0 && (
<p className="text-sm text-muted-foreground">Bu bölümde henüz ayar bulunmuyor.</p>
)}
{filteredContent.map((item) => (
<div key={item.key} className="space-y-2">
<Label className="capitalize">
{item.key.replace(/_/g, ' ').replace(activeSection, '').trim() || item.key}
</Label>
{item.type === 'image_url' ? (
<ImageUpload
value={item.value}
onChange={(url) => handleChange(item.key, url)}
bucketName="public-site"
/>
) : item.type === 'long_text' || item.type === 'html' || item.key.includes('subtitle') || item.key.includes('address') ? (
<Textarea
value={item.value}
onChange={(e) => handleChange(item.key, e.target.value)}
rows={3}
/>
) : (
<Input
value={item.value}
onChange={(e) => handleChange(item.key, e.target.value)}
/>
)}
{item.key.includes('map_embed') && (
<p className="text-xs text-muted-foreground mt-1">
Google Maps'e gidin {'>'} Paylaş {'>'} Harita yerleştir {'>'} HTML'i kopyala diyerek aldığınız kodu buraya yapıştırın.
(iframe ile başlayan kod olmalı)
</p>
)}
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end sticky bottom-6">
<Button onClick={onSubmit} disabled={loading} size="lg">
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Değişiklikleri Kaydet
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { createClient } from "@/lib/supabase/server"
import { ContentForm } from "./content-form"
import { SiteContent } from "@/types/cms"
export default async function ContentPage() {
const supabase = await createClient()
const { data: contents } = await supabase
.from('site_contents')
.select('*')
.order('key')
// CMS migration script creates default keys.
// If table is empty, we might want to seed it or just show empty.
// Assuming migration ran, we have data.
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Genel İçerik Yönetimi</h3>
<p className="text-sm text-muted-foreground">
Site başlığı, sloganlar, iletişim bilgileri ve logoları buradan yönetebilirsiniz.
</p>
</div>
<ContentForm initialContent={(contents as SiteContent[]) || []} />
</div>
)
}

View File

@@ -0,0 +1,93 @@
'use server'
import { createClient } from "@/lib/supabase/server"
import { GalleryItem } from "@/types/cms"
import { revalidatePath } from "next/cache"
export async function getGalleryItems() {
const supabase = await createClient()
const { data } = await supabase
.from('gallery')
.select('*')
.order('order', { ascending: true })
return data as GalleryItem[] || []
}
export async function createGalleryItem(formData: FormData) {
const supabase = await createClient()
const image_url = formData.get('image_url') as string
const caption = formData.get('caption') as string
const category = formData.get('category') as string
const order = Number(formData.get('order') || 0)
const is_hero = formData.get('is_hero') === 'true'
const video_url = formData.get('video_url') as string
const { error } = await supabase
.from('gallery')
.insert({
image_url,
caption,
category,
order,
is_hero,
video_url
})
if (error) {
console.error('Create gallery item error:', error)
return { success: false, error: 'Fotoğraf eklenirken hata oluştu.' }
}
revalidatePath('/dashboard/cms/gallery')
return { success: true }
}
export async function updateGalleryItem(id: string, formData: FormData) {
const supabase = await createClient()
const image_url = formData.get('image_url') as string
const caption = formData.get('caption') as string
const category = formData.get('category') as string
const order = Number(formData.get('order') || 0)
const is_hero = formData.get('is_hero') === 'true'
const video_url = formData.get('video_url') as string
const { error } = await supabase
.from('gallery')
.update({
image_url,
caption,
category,
order,
is_hero,
video_url
})
.eq('id', id)
if (error) {
console.error('Update gallery item error:', error)
return { success: false, error: 'Fotoğraf güncellenirken hata oluştu.' }
}
revalidatePath('/dashboard/cms/gallery')
return { success: true }
}
export async function deleteGalleryItem(id: string) {
const supabase = await createClient()
const { error } = await supabase
.from('gallery')
.delete()
.eq('id', id)
if (error) {
console.error('Delete gallery item error:', error)
return { success: false, error: 'Fotoğraf silinirken hata oluştu.' }
}
revalidatePath('/dashboard/cms/gallery')
return { success: true }
}

View File

@@ -0,0 +1,193 @@
'use client'
import { GalleryItem } from "@/types/cms"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ImageUpload } from "@/components/ui/image-upload"
import { useState } from "react"
import { createGalleryItem, updateGalleryItem } from "./actions"
import { toast } from "sonner"
import { Switch } from "@/components/ui/switch"
import { Loader2 } from "lucide-react"
import { VideoUpload } from "@/components/ui/video-upload"
import { cn } from "@/lib/utils"
interface GalleryFormProps {
item?: GalleryItem
afterSave: () => void
}
export function GalleryForm({ item, afterSave }: GalleryFormProps) {
const [loading, setLoading] = useState(false)
const [imageUrl, setImageUrl] = useState(item?.image_url || "")
const [caption, setCaption] = useState(item?.caption || "")
const [category, setCategory] = useState(item?.category || "Genel")
const [order, setOrder] = useState(item?.order.toString() || "0")
const [isHero, setIsHero] = useState(item?.is_hero || false)
const [videoUrl, setVideoUrl] = useState(item?.video_url || "")
const [videoInputType, setVideoInputType] = useState<'url' | 'file'>('url')
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const formData = new FormData()
formData.append('image_url', imageUrl)
formData.append('caption', caption)
formData.append('category', category)
formData.append('order', order)
formData.append('is_hero', String(isHero))
formData.append('video_url', videoUrl)
try {
const result = item
? await updateGalleryItem(item.id, formData)
: await createGalleryItem(formData)
if (result.success) {
toast.success(item ? "Fotoğraf güncellendi" : "Fotoğraf eklendi")
afterSave()
} else {
toast.error(result.error)
}
} catch (error) {
toast.error("Bir hata oluştu")
} finally {
setLoading(false)
}
}
// New Logic for Youtube Thumbnail
// Only works if imageUrl is empty to avoid overwriting user uploaded images
const getYoutubeId = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/
const match = url.match(regExp)
return (match && match[2].length === 11) ? match[2] : null
}
const checkAndSetThumbnail = (url: string) => {
if (!url) return
const id = getYoutubeId(url)
if (id && !imageUrl) {
// maxresdefault might not exist for some videos, but is standard for most.
// hqdefault is safer but lower res.
setImageUrl(`https://img.youtube.com/vi/${id}/maxresdefault.jpg`)
toast.info("Youtube kapak fotoğrafı otomatik alındı")
}
}
// We can call this on change or effect. Using Effect is reactive.
// However, better to verify we don't overwrite if user manually set logic.
// The requirement is: "Video eklmesi yapıldığında otomatik youtube da olduğu gibi görseli gelsin"
// So if I paste a url, it should set the image.
const handleVideoUrlChange = (value: string) => {
setVideoUrl(value)
checkAndSetThumbnail(value)
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Fotoğraf</Label>
<ImageUpload
value={imageUrl}
onChange={setImageUrl}
bucketName="public-site"
/>
</div>
<div className="space-y-3 border rounded-lg p-4 bg-slate-50">
<div className="flex items-center justify-between">
<Label>Video (Opsiyonel)</Label>
<div className="flex items-center bg-white rounded-md border p-1 scale-90">
<button
type="button"
onClick={() => setVideoInputType('url')}
className={cn(
"px-3 py-1 rounded text-xs font-medium transition-colors",
videoInputType === 'url' ? "bg-slate-900 text-white shadow-sm" : "hover:bg-slate-100"
)}
>
Link
</button>
<button
type="button"
onClick={() => setVideoInputType('file')}
className={cn(
"px-3 py-1 rounded text-xs font-medium transition-colors",
videoInputType === 'file' ? "bg-slate-900 text-white shadow-sm" : "hover:bg-slate-100"
)}
>
Dosya Yükle
</button>
</div>
</div>
{videoInputType === 'url' ? (
<Input
value={videoUrl}
onChange={(e) => handleVideoUrlChange(e.target.value)}
placeholder="Youtube embed link veya video bağlantısı"
/>
) : (
<VideoUpload
value={videoUrl}
onChange={setVideoUrl}
bucketName="public-site"
/>
)}
<p className="text-xs text-muted-foreground">
Video eklediğinizde fotoğraf kapak görseli olarak kullanılacaktır.
</p>
</div>
<div className="flex items-center space-x-2 py-2">
<Switch
checked={isHero}
onCheckedChange={setIsHero}
/>
<div className="space-y-0.5">
<Label>Vitrinde Gözüksün</Label>
<p className="text-xs text-muted-foreground">İşaretlenirse anasayfa giriş ekranında (slider) gösterilir.</p>
</div>
</div>
<div className="space-y-2">
<Label>Başlık / ıklama (Opsiyonel)</Label>
<Input
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Örn: Gelin Masası"
/>
</div>
<div className="space-y-2">
<Label>Kategori</Label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Genel, Düğün, Nişan vb."
/>
</div>
<div className="space-y-2">
<Label>Sıralama</Label>
<Input
type="number"
value={order}
onChange={(e) => setOrder(e.target.value)}
/>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{item ? 'Güncelle' : 'Ekle'}
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { GalleryItem } from "@/types/cms"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Plus, Pencil, Trash2 } from "lucide-react"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { GalleryForm } from "./gallery-form"
import { deleteGalleryItem } from "./actions"
import { toast } from "sonner"
import Image from "next/image"
interface GalleryListProps {
initialItems: GalleryItem[]
}
export function GalleryList({ initialItems }: GalleryListProps) {
const [open, setOpen] = useState(false)
const [editingItem, setEditingItem] = useState<GalleryItem | null>(null)
const handleDelete = async (id: string) => {
if (!confirm('Bu fotoğrafı silmek istediğinize emin misiniz?')) return
const result = await deleteGalleryItem(id)
if (result.success) {
toast.success("Fotoğraf silindi")
} else {
toast.error(result.error)
}
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => setEditingItem(null)}>
<Plus className="mr-2 h-4 w-4" /> Yeni Fotoğraf Ekle
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingItem ? 'Fotoğrafı Düzenle' : 'Yeni Fotoğraf Ekle'}</DialogTitle>
</DialogHeader>
<GalleryForm
item={editingItem || undefined}
afterSave={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{initialItems.length === 0 && (
<div className="col-span-full text-center py-10 text-muted-foreground border border-dashed rounded-lg">
Henüz fotoğraf eklenmemiş.
</div>
)}
{initialItems.map((item) => {
let displayImage = item.image_url
if (!displayImage && item.video_url) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/
const match = item.video_url.match(regExp)
const id = (match && match[2].length === 11) ? match[2] : null
if (id) displayImage = `https://img.youtube.com/vi/${id}/maxresdefault.jpg`
}
return (
<Card key={item.id} className="overflow-hidden group">
<CardContent className="p-0 relative aspect-square">
{displayImage ? (
<Image
src={displayImage}
alt={item.caption || "Gallery Image"}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex items-center justify-center h-full w-full bg-muted">
<span className="text-xs text-muted-foreground">Resim Yok</span>
</div>
)}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-4">
<div className="text-white">
<p className="font-medium truncate">{item.caption || '-'}</p>
<p className="text-xs text-white/80">{item.category}</p>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={() => {
setEditingItem(item)
setOpen(true)
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { getGalleryItems } from "./actions"
import { GalleryList } from "./gallery-list"
export default async function GalleryPage() {
const items = await getGalleryItems()
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Galeri Yönetimi</h3>
<p className="text-sm text-muted-foreground">
Web sitesinde görünecek fotoğrafları buradan yönetebilirsiniz.
</p>
</div>
<GalleryList initialItems={items} />
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { Separator } from "@/components/ui/separator"
import { LayoutDashboard, FileText, Image, Briefcase } from "lucide-react"
import Link from "next/link"
const sidebarNavItems = [
{
title: "İçerik Yönetimi",
href: "/dashboard/cms/content",
icon: <FileText className="w-5 h-5 mr-2" />,
},
{
title: "Hizmetler",
href: "/dashboard/cms/services",
icon: <Briefcase className="w-5 h-5 mr-2" />,
},
{
title: "Galeri",
href: "/dashboard/cms/gallery",
icon: <Image className="w-5 h-5 mr-2" />,
},
]
interface CmsLayoutProps {
children: React.ReactNode
}
export default function CmsLayout({ children }: CmsLayoutProps) {
return (
<div className="space-y-6 p-10 pb-16 md:block">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">İçerik Yönetim Sistemi (CMS)</h2>
<p className="text-muted-foreground">
Kurumsal web sitesi içeriklerini, görselleri ve hizmetleri buradan yönetebilirsiniz.
</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<nav className="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
{sidebarNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
{item.icon}
{item.title}
</Link>
))}
</nav>
</aside>
<div className="flex-1 lg:max-w-4xl">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function CmsPage() {
redirect('/dashboard/cms/content')
}

View File

@@ -0,0 +1,88 @@
'use server'
import { createClient } from "@/lib/supabase/server"
import { Service } from "@/types/cms"
import { revalidatePath } from "next/cache"
export async function getServices() {
const supabase = await createClient()
const { data } = await supabase
.from('services')
.select('*')
.order('order', { ascending: true })
return data as Service[] || []
}
export async function createService(formData: FormData) {
const supabase = await createClient()
const title = formData.get('title') as string
const description = formData.get('description') as string
const image_url = formData.get('image_url') as string
const order = Number(formData.get('order') || 0)
const { error } = await supabase
.from('services')
.insert({
title,
description,
image_url,
order,
is_active: true
})
if (error) {
console.error('Create service error:', error)
return { success: false, error: 'Hizmet oluşturulurken hata oluştu.' }
}
revalidatePath('/dashboard/cms/services')
return { success: true }
}
export async function updateService(id: string, formData: FormData) {
const supabase = await createClient()
const title = formData.get('title') as string
const description = formData.get('description') as string
const image_url = formData.get('image_url') as string
const order = Number(formData.get('order') || 0)
const is_active = formData.get('is_active') === 'true'
const { error } = await supabase
.from('services')
.update({
title,
description,
image_url,
order,
is_active
})
.eq('id', id)
if (error) {
console.error('Update service error:', error)
return { success: false, error: 'Hizmet güncellenirken hata oluştu.' }
}
revalidatePath('/dashboard/cms/services')
return { success: true }
}
export async function deleteService(id: string) {
const supabase = await createClient()
const { error } = await supabase
.from('services')
.delete()
.eq('id', id)
if (error) {
console.error('Delete service error:', error)
return { success: false, error: 'Hizmet silinirken hata oluştu.' }
}
revalidatePath('/dashboard/cms/services')
return { success: true }
}

View File

@@ -0,0 +1,18 @@
import { getServices } from "./actions"
import { ServiceList } from "./service-list"
export default async function ServicesPage() {
const services = await getServices()
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Hizmet Yönetimi</h3>
<p className="text-sm text-muted-foreground">
Salonda sunulan hizmetleri bu sayfadan ekleyip düzenleyebilirsiniz.
</p>
</div>
<ServiceList initialServices={services} />
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { Service } from "@/types/cms"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { ImageUpload } from "@/components/ui/image-upload"
import { useState } from "react"
import { createService, updateService } from "./actions"
import { toast } from "sonner"
import { Switch } from "@/components/ui/switch"
import { Loader2 } from "lucide-react"
interface ServiceFormProps {
service?: Service
afterSave: () => void
}
export function ServiceForm({ service, afterSave }: ServiceFormProps) {
const [loading, setLoading] = useState(false)
const [title, setTitle] = useState(service?.title || "")
const [description, setDescription] = useState(service?.description || "")
const [imageUrl, setImageUrl] = useState(service?.image_url || "")
const [order, setOrder] = useState(service?.order.toString() || "0")
const [isActive, setIsActive] = useState(service?.is_active ?? true)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
const formData = new FormData()
formData.append('title', title)
formData.append('description', description)
formData.append('image_url', imageUrl)
formData.append('order', order)
formData.append('is_active', String(isActive))
try {
const result = service
? await updateService(service.id, formData)
: await createService(formData)
if (result.success) {
toast.success(service ? "Hizmet güncellendi" : "Hizmet oluşturuldu")
afterSave()
} else {
toast.error(result.error)
}
} catch (error) {
toast.error("Bir hata oluştu")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Hizmet Başlığı</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Örn: Düğün Organizasyonu"
required
/>
</div>
<div className="space-y-2">
<Label>ıklama</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Hizmet detayları..."
/>
</div>
<div className="space-y-2">
<Label>Görsel</Label>
<ImageUpload
value={imageUrl}
onChange={setImageUrl}
bucketName="public-site"
/>
</div>
<div className="flex items-center gap-4">
<div className="space-y-2 flex-1">
<Label>Sıralama</Label>
<Input
type="number"
value={order}
onChange={(e) => setOrder(e.target.value)}
/>
</div>
<div className="space-y-2 flex flex-col pt-6">
<div className="flex items-center space-x-2">
<Switch
checked={isActive}
onCheckedChange={setIsActive}
/>
<Label>Aktif</Label>
</div>
</div>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{service ? 'Güncelle' : 'Oluştur'}
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import { Service } from "@/types/cms"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Plus, Pencil, Trash2, GripVertical } from "lucide-react"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { ServiceForm } from "./service-form"
import { deleteService } from "./actions"
import { toast } from "sonner"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
interface ServiceListProps {
initialServices: Service[]
}
export function ServiceList({ initialServices }: ServiceListProps) {
const [open, setOpen] = useState(false)
const [editingService, setEditingService] = useState<Service | null>(null)
const handleDelete = async (id: string) => {
if (!confirm('Bu hizmeti silmek istediğinize emin misiniz?')) return
const result = await deleteService(id)
if (result.success) {
toast.success("Hizmet silindi")
} else {
toast.error(result.error)
}
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => setEditingService(null)}>
<Plus className="mr-2 h-4 w-4" /> Yeni Hizmet Ekle
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingService ? 'Hizmeti Düzenle' : 'Yeni Hizmet Ekle'}</DialogTitle>
</DialogHeader>
<ServiceForm
service={editingService || undefined}
afterSave={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{initialServices.length === 0 && (
<div className="text-center py-10 text-muted-foreground border border-dashed rounded-lg">
Henüz hizmet eklenmemiş.
</div>
)}
{initialServices.map((service) => (
<Card key={service.id} className="overflow-hidden">
<CardContent className="p-0 flex items-center gap-4">
<div className="h-24 w-24 relative bg-muted shrink-0">
{service.image_url ? (
<Image
src={service.image_url}
alt={service.title}
fill
className="object-cover"
/>
) : (
<div className="flex items-center justify-center h-full w-full">
<span className="text-xs text-muted-foreground">Resim Yok</span>
</div>
)}
</div>
<div className="flex-1 py-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{service.title}</h3>
{!service.is_active && <Badge variant="secondary">Pasif</Badge>}
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{service.description}
</p>
</div>
<div className="flex items-center gap-2 pr-4">
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditingService(service)
setOpen(true)
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(service.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import Link from "next/link"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Utensils, Users, Activity } from "lucide-react"
import { Utensils, Users, Activity, Globe } from "lucide-react"
export default function SettingsPage() {
return (
@@ -45,6 +45,19 @@ export default function SettingsPage() {
</CardHeader>
</Card>
</Link>
<Link href="/dashboard/cms">
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" /> Site Yönetimi
</CardTitle>
<CardDescription>
Kurumsal web sitesi içeriklerini, hizmetleri ve galeriyi yönetin.
</CardDescription>
</CardHeader>
</Card>
</Link>
</div>
</div>
)

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/dashboard')
}

View File

@@ -0,0 +1,107 @@
"use client"
import { GalleryItem } from "@/types/cms"
import Image from "next/image"
import { useState } from "react"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
import { Play } from "lucide-react"
interface GalleryGridProps {
items: GalleryItem[]
}
export function GalleryGrid({ items }: GalleryGridProps) {
const [selectedVideo, setSelectedVideo] = useState<string | null>(null)
const isYoutube = (url: string) => {
return url.includes('youtube.com') || url.includes('youtu.be')
}
const getYoutubeEmbedUrl = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/
const match = url.match(regExp)
const id = (match && match[2].length === 11) ? match[2] : null
return id ? `https://www.youtube.com/embed/${id}?autoplay=1` : url
}
const getImageUrl = (item: GalleryItem) => {
if (item.image_url) return item.image_url
if (item.video_url) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/
const match = item.video_url.match(regExp)
const id = (match && match[2].length === 11) ? match[2] : null
if (id) return `https://img.youtube.com/vi/${id}/maxresdefault.jpg`
}
return null
}
return (
<>
<div className="flex flex-wrap justify-center gap-4">
{items?.map((item) => {
const displayImage = getImageUrl(item)
return (
<div
key={item.id}
className="relative overflow-hidden rounded-xl group w-full sm:w-[calc(50%-8px)] lg:w-[calc(25%-12px)] aspect-square cursor-pointer"
onClick={() => {
if (item.video_url) {
setSelectedVideo(item.video_url)
}
}}
>
{displayImage && (
<Image
src={displayImage}
alt={item.caption || 'Gallery Image'}
fill
className="object-cover transition-transform duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 50vw, 25vw"
/>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col items-center justify-center space-y-2">
{item.video_url && (
<div className="w-12 h-12 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mb-2 transition-transform group-hover:scale-110">
<Play className="w-6 h-6 text-white fill-current" />
</div>
)}
<span className="text-white font-medium text-lg tracking-wide">
{item.category}
</span>
{item.video_url && <span className="text-white/80 text-xs">İzle</span>}
</div>
</div>
)
})}
</div>
<Dialog open={!!selectedVideo} onOpenChange={() => setSelectedVideo(null)}>
<DialogContent className="sm:max-w-4xl p-0 bg-black overflow-hidden border-none dialog-content-video">
<VisuallyHidden>
<DialogTitle>Video Oynatıcı</DialogTitle>
</VisuallyHidden>
{selectedVideo && (
<div className="relative w-full aspect-video">
{isYoutube(selectedVideo) ? (
<iframe
src={getYoutubeEmbedUrl(selectedVideo)}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<video
src={selectedVideo}
controls
autoPlay
className="absolute inset-0 w-full h-full"
/>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { useState, useEffect } from "react"
import { GalleryItem } from "@/types/cms"
import Image from "next/image"
import { cn } from "@/lib/utils"
interface HeroCarouselProps {
images: GalleryItem[]
children?: React.ReactNode
}
export function HeroCarousel({ images, children }: HeroCarouselProps) {
const [current, setCurrent] = useState(0)
useEffect(() => {
if (images.length <= 1) return
const timer = setInterval(() => {
setCurrent((prev) => (prev + 1) % images.length)
}, 5000)
return () => clearInterval(timer)
}, [images.length])
const getImageUrl = (item: GalleryItem) => {
if (item.image_url) return item.image_url
if (item.video_url) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/
const match = item.video_url.match(regExp)
const id = (match && match[2].length === 11) ? match[2] : null
if (id) return `https://img.youtube.com/vi/${id}/maxresdefault.jpg`
}
return null
}
// Fallback if no images
if (!images || images.length === 0) {
return (
<div className="absolute inset-0 bg-slate-900">
<div
className="absolute inset-0 bg-cover bg-center opacity-50"
style={{ backgroundImage: 'url("https://images.unsplash.com/photo-1519167758481-83f550bb49b3?q=80&w=2098&auto=format&fit=crop")' }}
/>
{children}
</div>
)
}
return (
<>
{images.map((img, idx) => {
const displayImage = getImageUrl(img)
if (!displayImage) return null;
return (
<div
key={img.id}
className={cn(
"absolute inset-0 transition-opacity duration-1000",
idx === current ? "opacity-100 z-0" : "opacity-0 -z-10"
)}
>
<Image
src={displayImage}
alt="Hero Image"
fill
className="object-cover"
priority={idx === 0}
/>
<div className="absolute inset-0 bg-black/40" />
</div>
)
})}
{/* Indicators */}
{images.length > 1 && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-30 flex space-x-2">
{images.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrent(idx)}
className={cn(
"w-2 h-2 rounded-full transition-all",
idx === current ? "bg-white w-4" : "bg-white/50 hover:bg-white/80"
)}
/>
))}
</div>
)}
{children}
</>
)
}

View File

@@ -0,0 +1,72 @@
import Link from "next/link"
import { Facebook, Instagram, Twitter } from "lucide-react"
interface SiteFooterProps {
siteTitle: string
phone?: string
address?: string
email?: string
description?: string
socials?: {
instagram?: string
facebook?: string
twitter?: string
}
}
export function SiteFooter({ siteTitle, phone, address, email, description, socials }: SiteFooterProps) {
return (
<footer className="border-t bg-muted/40">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-10 md:py-16">
<div className="grid grid-cols-1 gap-8 md:grid-cols-4">
<div className="space-y-4">
<h3 className="text-lg font-bold">{siteTitle}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{description || 'Hayallerinizdeki düğünü gerçeğe dönüştürmek için profesyonel ekibimiz ve şık salonumuzla hizmetinizdeyiz.'}
</p>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold">Hızlı Linkler</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/" className="hover:text-primary">Anasayfa</Link></li>
<li><Link href="#features" className="hover:text-primary">Hizmetler</Link></li>
<li><Link href="#gallery" className="hover:text-primary">Galeri</Link></li>
<li><Link href="#contact" className="hover:text-primary">İletişim</Link></li>
</ul>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold">İletişim</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>{address || 'Adres bilgisi bulunamadı.'}</li>
<li>{phone || 'Telefon bilgisi bulunamadı.'}</li>
<li>{email || 'E-posta bilgisi bulunamadı.'}</li>
</ul>
</div>
<div className="space-y-4">
<h4 className="text-sm font-semibold">Bizi Takip Edin</h4>
<div className="flex space-x-4">
{socials?.instagram && (
<Link href={socials.instagram} target="_blank" className="text-muted-foreground hover:text-primary">
<Instagram className="h-5 w-5" />
</Link>
)}
{socials?.facebook && (
<Link href={socials.facebook} target="_blank" className="text-muted-foreground hover:text-primary">
<Facebook className="h-5 w-5" />
</Link>
)}
{socials?.twitter && (
<Link href={socials.twitter} target="_blank" className="text-muted-foreground hover:text-primary">
<Twitter className="h-5 w-5" />
</Link>
)}
</div>
</div>
</div>
<div className="mt-10 border-t pt-8 text-center text-sm text-muted-foreground">
© {new Date().getFullYear()} {siteTitle}. Tüm hakları saklıdır.
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,52 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
import Image from "next/image"
interface SiteHeaderProps {
siteTitle?: string
siteLogo?: string
}
export function SiteHeader({ siteTitle = "Rüya Düğün Salonu", siteLogo }: SiteHeaderProps) {
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="w-full px-4 md:px-8 flex h-16 items-center justify-between">
<Link href="/" className="flex items-center space-x-3">
{siteLogo && (
<div className="relative h-10 w-10 overflow-hidden rounded-full border">
<Image
src={siteLogo}
alt="Logo"
fill
className="object-cover"
/>
</div>
)}
<span className="text-xl font-bold bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
{siteTitle}
</span>
</Link>
<nav className="flex items-center gap-6 text-sm font-medium">
<Link href="#features" className="transition-colors hover:text-primary">
Hizmetler
</Link>
<Link href="#gallery" className="transition-colors hover:text-primary">
Galeri
</Link>
<Link href="#contact" className="transition-colors hover:text-primary">
İletişim
</Link>
<div className="flex items-center gap-2">
<Link href="#contact">
<Button size="sm">Randevu Al</Button>
</Link>
<Link href="/login">
<Button variant="outline" size="sm">Giriş Yap</Button>
</Link>
</div>
</nav>
</div>
</header>
)
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,104 @@
"use client"
import { ChangeEvent, useState } from "react"
import { Button } from "@/components/ui/button"
import { createClient } from "@/lib/supabase/client"
import { Video, Loader2, X } from "lucide-react"
import { toast } from "sonner"
interface VideoUploadProps {
value: string
onChange: (url: string) => void
disabled?: boolean
bucketName?: string
}
export function VideoUpload({
value,
onChange,
disabled,
bucketName = "public-site"
}: VideoUploadProps) {
const [isUploading, setIsUploading] = useState(false)
const supabase = createClient()
const onUpload = async (e: ChangeEvent<HTMLInputElement>) => {
try {
const file = e.target.files?.[0]
if (!file) return
// 50MB limit check (optional, but good practice)
if (file.size > 50 * 1024 * 1024) {
toast.error("Dosya boyutu 50MB'dan büyük olamaz")
return
}
setIsUploading(true)
const fileExt = file.name.split('.').pop()
const fileName = `videos/${Math.random()}.${fileExt}` // Put in videos subfolder
const { error: uploadError } = await supabase.storage
.from(bucketName)
.upload(fileName, file)
if (uploadError) {
throw uploadError
}
const { data } = supabase.storage
.from(bucketName)
.getPublicUrl(fileName)
onChange(data.publicUrl)
toast.success("Video yüklendi")
} catch (error) {
toast.error("Video yüklenirken hata oluştu")
console.error(error)
} finally {
setIsUploading(false)
}
}
if (value) {
return (
<div className="relative w-full max-w-sm rounded-md overflow-hidden border bg-slate-100 p-2 flex items-center gap-2">
<Video className="h-4 w-4 text-blue-500" />
<span className="text-xs truncate flex-1">{value}</span>
<Button
type="button"
onClick={() => onChange("")}
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
)
}
return (
<div className="w-full max-w-sm h-[100px] rounded-md border-dashed border-2 flex items-center justify-center bg-muted/50 relative hover:opacity-75 transition disabled:opacity-50 disabled:cursor-not-allowed">
<input
type="file"
accept="video/mp4,video/webm,video/ogg,video/quicktime"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
onChange={onUpload}
disabled={disabled || isUploading}
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<div className="text-xs text-muted-foreground">Yükleniyor...</div>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Video className="h-8 w-8 text-muted-foreground" />
<div className="text-xs text-muted-foreground">Video Yükle (Maks 50MB)</div>
</div>
)}
</div>
)
}

27
src/types/cms.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface SiteContent {
key: string
value: string
type: 'text' | 'image_url' | 'html' | 'json' | 'long_text'
section: string
}
export interface Service {
id: string
title: string
description: string | null
image_url: string | null
order: number
is_active: boolean
created_at: string
}
export interface GalleryItem {
id: string
image_url: string
caption: string | null
category: string
order: number
created_at: string
is_hero?: boolean
video_url?: string | null
}