feat: Complete Corporate Site Deployment Checkpoint
This commit is contained in:
3
add_email_field.sql
Normal file
3
add_email_field.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
INSERT INTO public.site_contents (key, value, type, section)
|
||||
VALUES ('contact_email', 'info@ruyadugun.com', 'text', 'contact')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
1
add_is_hero_column.sql
Normal file
1
add_is_hero_column.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.gallery ADD COLUMN IF NOT EXISTS is_hero BOOLEAN DEFAULT false;
|
||||
3
add_map_field.sql
Normal file
3
add_map_field.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
INSERT INTO public.site_contents (key, value, type, section)
|
||||
VALUES ('contact_map_embed', '<iframe src="..."></iframe>', 'html', 'contact')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
5
add_social_fields.sql
Normal file
5
add_social_fields.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
INSERT INTO public.site_contents (key, value, type, section) VALUES
|
||||
('social_instagram', 'https://instagram.com/', 'text', 'contact'),
|
||||
('social_facebook', 'https://facebook.com/', 'text', 'contact'),
|
||||
('social_twitter', 'https://twitter.com/', 'text', 'contact')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
1
add_video_url_column.sql
Normal file
1
add_video_url_column.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.gallery ADD COLUMN IF NOT EXISTS video_url TEXT;
|
||||
123
cms_migration.sql
Normal file
123
cms_migration.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- Enable UUID extension if not enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- 1. Site Contents Table (Genel İçerikler)
|
||||
CREATE TABLE IF NOT EXISTS public.site_contents (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
type TEXT DEFAULT 'text', -- 'text', 'image_url', 'html', 'json'
|
||||
section TEXT, -- 'home', 'about', 'contact', 'footer'
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- RLS: Public read, Admin write
|
||||
ALTER TABLE public.site_contents ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public read access on site_contents" ON public.site_contents;
|
||||
CREATE POLICY "Allow public read access on site_contents"
|
||||
ON public.site_contents FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow authenticated update on site_contents" ON public.site_contents;
|
||||
CREATE POLICY "Allow authenticated update on site_contents"
|
||||
ON public.site_contents FOR UPDATE TO authenticated
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow authenticated insert on site_contents" ON public.site_contents;
|
||||
CREATE POLICY "Allow authenticated insert on site_contents"
|
||||
ON public.site_contents FOR INSERT TO authenticated
|
||||
WITH CHECK (true);
|
||||
|
||||
-- 2. Services Table (Hizmetler)
|
||||
CREATE TABLE IF NOT EXISTS public.services (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
"order" INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE public.services ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public read on services" ON public.services;
|
||||
CREATE POLICY "Allow public read on services"
|
||||
ON public.services FOR SELECT TO anon, authenticated
|
||||
USING (is_active = true OR auth.role() = 'authenticated');
|
||||
|
||||
DROP POLICY IF EXISTS "Allow admin all on services" ON public.services;
|
||||
CREATE POLICY "Allow admin all on services"
|
||||
ON public.services FOR ALL TO authenticated
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- 3. Gallery Table (Galeri)
|
||||
CREATE TABLE IF NOT EXISTS public.gallery (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
image_url TEXT NOT NULL,
|
||||
caption TEXT,
|
||||
category TEXT DEFAULT 'Genel',
|
||||
"order" INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE public.gallery ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public read on gallery" ON public.gallery;
|
||||
CREATE POLICY "Allow public read on gallery"
|
||||
ON public.gallery FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow admin all on gallery" ON public.gallery;
|
||||
CREATE POLICY "Allow admin all on gallery"
|
||||
ON public.gallery FOR ALL TO authenticated
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
|
||||
-- 4. Storage Bucket for Public Site
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('public-site', 'public-site', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Storage Policies (Renamed to avoid conflicts)
|
||||
-- Note: 'storage.objects' is a shared table, so policy names must be unique or scoped.
|
||||
|
||||
-- Allow public read SPECIFIC to this bucket
|
||||
DROP POLICY IF EXISTS "Public Access public-site" ON storage.objects;
|
||||
CREATE POLICY "Public Access public-site"
|
||||
ON storage.objects FOR SELECT
|
||||
TO public
|
||||
USING ( bucket_id = 'public-site' );
|
||||
|
||||
-- Allow authenticated upload/delete SPECIFIC to this bucket
|
||||
DROP POLICY IF EXISTS "Authenticated Insert public-site" ON storage.objects;
|
||||
CREATE POLICY "Authenticated Insert public-site"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK ( bucket_id = 'public-site' );
|
||||
|
||||
DROP POLICY IF EXISTS "Authenticated Update public-site" ON storage.objects;
|
||||
CREATE POLICY "Authenticated Update public-site"
|
||||
ON storage.objects FOR UPDATE
|
||||
TO authenticated
|
||||
USING ( bucket_id = 'public-site' );
|
||||
|
||||
DROP POLICY IF EXISTS "Authenticated Delete public-site" ON storage.objects;
|
||||
CREATE POLICY "Authenticated Delete public-site"
|
||||
ON storage.objects FOR DELETE
|
||||
TO authenticated
|
||||
USING ( bucket_id = 'public-site' );
|
||||
|
||||
-- Seed Data (Initial Content)
|
||||
INSERT INTO public.site_contents (key, value, type, section) VALUES
|
||||
('site_title', 'Rüya Düğün Salonu', 'text', 'general'),
|
||||
('hero_title', 'Hayallerinizdeki Düğün İçin', 'text', 'home'),
|
||||
('hero_subtitle', 'Unutulmaz anlar, profesyonel hizmet ve şık atmosfer.', 'text', 'home'),
|
||||
('contact_phone', '+90 555 123 45 67', 'text', 'contact'),
|
||||
('contact_address', 'Atatürk Mah. Karanfil Sok. No:5, İstanbul', 'text', 'contact'),
|
||||
('site_logo', '', 'image_url', 'general')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -10,6 +10,18 @@ const nextConfig: NextConfig = {
|
||||
port: '',
|
||||
pathname: '/storage/v1/object/public/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'img.youtube.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -19,6 +19,8 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.86.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -2563,6 +2565,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
@@ -2604,6 +2629,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -2759,12 +2869,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.4.tgz",
|
||||
"integrity": "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2781,47 +2891,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.86.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -51,4 +53,4 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
src/app/(public)/galeri/page.tsx
Normal file
37
src/app/(public)/galeri/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/app/(public)/layout.tsx
Normal file
45
src/app/(public)/layout.tsx
Normal 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
186
src/app/(public)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/app/dashboard/cms/content/actions.ts
Normal file
34
src/app/dashboard/cms/content/actions.ts
Normal 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.' }
|
||||
}
|
||||
}
|
||||
129
src/app/dashboard/cms/content/content-form.tsx
Normal file
129
src/app/dashboard/cms/content/content-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/app/dashboard/cms/content/page.tsx
Normal file
28
src/app/dashboard/cms/content/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/app/dashboard/cms/gallery/actions.ts
Normal file
93
src/app/dashboard/cms/gallery/actions.ts
Normal 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 }
|
||||
}
|
||||
193
src/app/dashboard/cms/gallery/gallery-form.tsx
Normal file
193
src/app/dashboard/cms/gallery/gallery-form.tsx
Normal 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 / Açı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>
|
||||
)
|
||||
}
|
||||
123
src/app/dashboard/cms/gallery/gallery-list.tsx
Normal file
123
src/app/dashboard/cms/gallery/gallery-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
src/app/dashboard/cms/gallery/page.tsx
Normal file
18
src/app/dashboard/cms/gallery/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/app/dashboard/cms/layout.tsx
Normal file
56
src/app/dashboard/cms/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/app/dashboard/cms/page.tsx
Normal file
5
src/app/dashboard/cms/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function CmsPage() {
|
||||
redirect('/dashboard/cms/content')
|
||||
}
|
||||
88
src/app/dashboard/cms/services/actions.ts
Normal file
88
src/app/dashboard/cms/services/actions.ts
Normal 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 }
|
||||
}
|
||||
18
src/app/dashboard/cms/services/page.tsx
Normal file
18
src/app/dashboard/cms/services/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/app/dashboard/cms/services/service-form.tsx
Normal file
115
src/app/dashboard/cms/services/service-form.tsx
Normal 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>Açı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>
|
||||
)
|
||||
}
|
||||
118
src/app/dashboard/cms/services/service-list.tsx
Normal file
118
src/app/dashboard/cms/services/service-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
107
src/components/public/gallery-grid.tsx
Normal file
107
src/components/public/gallery-grid.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
95
src/components/public/hero-carousel.tsx
Normal file
95
src/components/public/hero-carousel.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
72
src/components/public/site-footer.tsx
Normal file
72
src/components/public/site-footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/public/site-header.tsx
Normal file
52
src/components/public/site-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal 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 }
|
||||
104
src/components/ui/video-upload.tsx
Normal file
104
src/components/ui/video-upload.tsx
Normal 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
27
src/types/cms.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user