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: '',
|
port: '',
|
||||||
pathname: '/storage/v1/object/public/**',
|
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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@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/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.86.0",
|
"@supabase/supabase-js": "^2.86.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"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": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.4.tgz",
|
||||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
"integrity": "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-primitive": "2.1.3"
|
"@radix-ui/react-primitive": "2.1.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@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": {
|
"node_modules/@radix-ui/rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
"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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@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/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.86.0",
|
"@supabase/supabase-js": "^2.86.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
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 Link from "next/link"
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
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() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +45,19 @@ export default function SettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</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>
|
||||||
</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