feat: hizmet ve proje detay sayfaları + galeri sistemi
Yeni Appwrite kolonları: - services: content (markdown), features[], faq[] (JSON-encoded), hero_image - projects: gallery[], content (markdown), client_name, industry, duration, service_slug Public sayfalar: - /hizmetler/[slug]: hero + features checklist + markdown content + FAQ accordion + ilgili projeler (service_slug eşleşmesi) - /projeler/[slug]: hero + meta tablosu (müşteri/sektör/süre/yıl) + kapak görseli + markdown vaka çalışması + lightbox galeri + diğer projeler Yeni componentler: - components/gallery.tsx: lightbox galeri (keyboard nav, prev/next, ESC kapat) - components/faq-list.tsx: accordion FAQ (tek seferde tek açık) Admin formları: - Hizmet formu: hero_image, content (markdown), features (virgülle), FAQ (her blok '---' ile ayrılır, ilk satır soru, kalanı cevap) - Proje formu: gallery (her satıra bir URL), content (markdown), client_name, industry, duration, service_slug (dropdown — hizmetlerden seçim) Linkler: - ServicesGrid kartları → /hizmetler/[slug] - ProjectsGrid kartları → /projeler/[slug] (live_url butonu ayrı, target=_blank) 29 route üretiliyor.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { marked } from "marked";
|
||||
import { getServiceBySlug, listProjects } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { ProjectsGrid } from "@/components/projects-grid";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { FaqList } from "@/components/faq-list";
|
||||
import type { FaqItem } from "@/lib/types";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const service = await getServiceBySlug(slug);
|
||||
if (!service) return { title: "Hizmet bulunamadı" };
|
||||
return buildMetadata(`/hizmetler/${slug}`, {
|
||||
title: service.title,
|
||||
description: service.description.slice(0, 160),
|
||||
});
|
||||
}
|
||||
|
||||
function parseFaq(items?: string[] | null): FaqItem[] {
|
||||
if (!items) return [];
|
||||
const out: FaqItem[] = [];
|
||||
for (const raw of items) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<FaqItem>;
|
||||
if (obj.q && obj.a) out.push({ q: obj.q, a: obj.a });
|
||||
} catch {
|
||||
const [q, a] = raw.split("|||").map((s) => s.trim());
|
||||
if (q && a) out.push({ q, a });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function ServiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const service = await getServiceBySlug(slug);
|
||||
if (!service) notFound();
|
||||
|
||||
const [relatedProjects] = await Promise.all([
|
||||
listProjects({ serviceSlug: slug, limit: 6 }),
|
||||
]);
|
||||
|
||||
const faqItems = parseFaq(service.faq);
|
||||
const html = service.content
|
||||
? (marked.parse(service.content, { async: false }) as string)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative overflow-hidden border-b border-[var(--border)]">
|
||||
<div className="absolute inset-0 hero-grid opacity-50" aria-hidden />
|
||||
<div className="relative mx-auto max-w-7xl px-6 py-20">
|
||||
<Link
|
||||
href="/hizmetler"
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> Tüm hizmetler
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 grid items-start gap-10 md:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-[var(--navy-50)] text-[var(--navy)]">
|
||||
<Icon name={service.icon} className="size-7" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-4xl font-bold tracking-tight text-[var(--navy)] sm:text-5xl">
|
||||
{service.title}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
|
||||
{service.description}
|
||||
</p>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-6 py-3 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
Bu hizmet için teklif al
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{service.hero_image && (
|
||||
<div className="relative hidden aspect-square w-64 overflow-hidden rounded-2xl md:block lg:w-80">
|
||||
<Image
|
||||
src={service.hero_image}
|
||||
alt={service.title}
|
||||
fill
|
||||
sizes="320px"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[2fr_1fr]">
|
||||
<div>
|
||||
{service.features && service.features.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-[var(--navy)]">
|
||||
Bu hizmet kapsamında
|
||||
</h2>
|
||||
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{service.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2 rounded-xl border border-[var(--border)] bg-white p-4"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--sky-600)]" />
|
||||
<span className="text-sm text-[var(--foreground)]">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{html && (
|
||||
<article
|
||||
className="prose prose-lg max-w-none text-[var(--foreground)]"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{faqItems.length > 0 && (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-[var(--navy)]">
|
||||
Sıkça sorulan sorular
|
||||
</h2>
|
||||
<div className="mt-6">
|
||||
<FaqList items={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-[var(--navy)] p-6 text-white">
|
||||
<h3 className="text-base font-semibold">Bir proje mi var?</h3>
|
||||
<p className="mt-2 text-sm text-white/70">
|
||||
İhtiyacınızı anlatın, size en uygun çözümü hep birlikte planlayalım.
|
||||
</p>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-medium text-[var(--navy)] transition hover:bg-[var(--sky-50)]"
|
||||
>
|
||||
Bize ulaşın
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Diğer hizmetler
|
||||
</h3>
|
||||
<Link
|
||||
href="/hizmetler"
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm text-[var(--sky-600)] hover:text-[var(--navy)]"
|
||||
>
|
||||
Tümünü gör
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{relatedProjects.length > 0 && (
|
||||
<section className="border-t border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<SectionTitle
|
||||
align="left"
|
||||
eyebrow="Referanslar"
|
||||
title={`${service.title} alanındaki projelerimiz`}
|
||||
description="Bu hizmette tamamladığımız işlerden seçkiler."
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<ProjectsGrid projects={relatedProjects} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Building2, Calendar, Clock, ExternalLink, Tag } from "lucide-react";
|
||||
import { marked } from "marked";
|
||||
import { getProjectBySlug, listProjects } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import { Gallery } from "@/components/gallery";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const project = await getProjectBySlug(slug);
|
||||
if (!project) return { title: "Proje bulunamadı" };
|
||||
return buildMetadata(`/projeler/${slug}`, {
|
||||
title: project.title,
|
||||
description: project.description.slice(0, 160),
|
||||
openGraph: {
|
||||
title: project.title,
|
||||
description: project.description.slice(0, 160),
|
||||
images: project.image_url ? [{ url: project.image_url }] : undefined,
|
||||
type: "article",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const project = await getProjectBySlug(slug);
|
||||
if (!project) notFound();
|
||||
|
||||
const html = project.content
|
||||
? (marked.parse(project.content, { async: false }) as string)
|
||||
: "";
|
||||
|
||||
const meta: { icon: React.ReactNode; label: string; value: string }[] = [];
|
||||
if (project.client_name)
|
||||
meta.push({ icon: <Building2 className="size-4" />, label: "Müşteri", value: project.client_name });
|
||||
if (project.industry)
|
||||
meta.push({ icon: <Tag className="size-4" />, label: "Sektör", value: project.industry });
|
||||
if (project.duration)
|
||||
meta.push({ icon: <Clock className="size-4" />, label: "Süre", value: project.duration });
|
||||
if (project.year)
|
||||
meta.push({ icon: <Calendar className="size-4" />, label: "Yıl", value: String(project.year) });
|
||||
|
||||
const relatedProjects = (
|
||||
await listProjects({ limit: 4 })
|
||||
).filter((p) => p.$id !== project.$id).slice(0, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto max-w-7xl px-6 py-12">
|
||||
<Link
|
||||
href="/projeler"
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> Tüm projeler
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 grid items-start gap-10 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div>
|
||||
{project.category && (
|
||||
<span className="inline-flex rounded-full bg-[var(--sky-50)] px-3 py-1 text-xs font-medium text-[var(--sky-600)]">
|
||||
{project.category}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="mt-3 text-4xl font-bold tracking-tight text-[var(--navy)] sm:text-5xl">
|
||||
{project.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg leading-relaxed text-[var(--muted)]">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{project.live_url && (
|
||||
<a
|
||||
href={project.live_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
Projeyi canlı görüntüle
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{project.technologies && project.technologies.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Teknolojiler
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{project.technologies.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded-md bg-[var(--navy-50)] px-2.5 py-1 text-xs text-[var(--navy-700)]"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meta.length > 0 && (
|
||||
<dl className="grid grid-cols-2 gap-4 rounded-2xl border border-[var(--border)] bg-white p-6">
|
||||
{meta.map((m) => (
|
||||
<div key={m.label}>
|
||||
<dt className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-[var(--muted)]">
|
||||
{m.icon}
|
||||
{m.label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-[var(--navy)]">
|
||||
{m.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.image_url && (
|
||||
<div className="relative mt-10 aspect-video overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src={project.image_url}
|
||||
alt={project.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 1024px, 100vw"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-16">
|
||||
{html && (
|
||||
<article
|
||||
className="prose prose-lg max-w-none text-[var(--foreground)]"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{project.gallery && project.gallery.length > 0 && (
|
||||
<section className={html ? "mt-16" : ""}>
|
||||
<h2 className="text-2xl font-bold text-[var(--navy)]">Galeri</h2>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Görsellerin üzerine tıklayarak büyütebilirsiniz.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Gallery images={project.gallery} alt={project.title} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{relatedProjects.length > 0 && (
|
||||
<section className="border-t border-[var(--border)] bg-[var(--navy-50)]/40 py-16">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<h2 className="text-2xl font-bold text-[var(--navy)]">
|
||||
Diğer projeler
|
||||
</h2>
|
||||
<div className="mt-8 grid gap-6 md:grid-cols-3">
|
||||
{relatedProjects.map((p) => (
|
||||
<Link
|
||||
key={p.$id}
|
||||
href={`/projeler/${p.slug}`}
|
||||
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
|
||||
{p.image_url ? (
|
||||
<Image
|
||||
src={p.image_url}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, 100vw"
|
||||
className="object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-3xl font-bold text-[var(--navy)]/30">
|
||||
{p.title.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h3 className="font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--muted)]">{p.category}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveService } from "@/lib/admin-actions";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
import type { FaqItem, ServiceRow } from "@/lib/types";
|
||||
|
||||
const ICON_OPTIONS = [
|
||||
"Globe",
|
||||
@@ -24,6 +24,20 @@ const ICON_OPTIONS = [
|
||||
"Layers",
|
||||
];
|
||||
|
||||
function faqToText(items?: string[] | null): string {
|
||||
if (!items) return "";
|
||||
const parsed: FaqItem[] = [];
|
||||
for (const raw of items) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<FaqItem>;
|
||||
if (obj.q && obj.a) parsed.push({ q: obj.q, a: obj.a });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return parsed.map((it) => `${it.q}\n${it.a}`).join("\n---\n");
|
||||
}
|
||||
|
||||
export function ServiceForm({ service }: { service?: ServiceRow }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -56,20 +70,56 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="mt-1 block text-xs text-[var(--muted)]">
|
||||
Lucide icon adı.
|
||||
</span>
|
||||
</label>
|
||||
<Field
|
||||
label="Hero görsel URL"
|
||||
name="hero_image"
|
||||
type="url"
|
||||
defaultValue={service?.hero_image}
|
||||
help="Detay sayfası başında gösterilir (opsiyonel)."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<Textarea
|
||||
label="Açıklama"
|
||||
label="Kısa açıklama (kart için)"
|
||||
name="description"
|
||||
required
|
||||
defaultValue={service?.description}
|
||||
rows={4}
|
||||
rows={3}
|
||||
help="Listede ve anasayfa kartında gösterilir."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Detay içerik (Markdown)"
|
||||
name="content"
|
||||
defaultValue={service?.content}
|
||||
rows={10}
|
||||
placeholder={"## Yaklaşım\n\nMarkdown desteklenir…"}
|
||||
help="Hizmet detay sayfasında ana içerik olarak gösterilir."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Özellikler"
|
||||
name="features"
|
||||
defaultValue={service?.features?.join(", ")}
|
||||
rows={3}
|
||||
placeholder="SEO uyumlu kod, Mobil responsive, Hızlı yüklenme, …"
|
||||
help="Virgülle ayırın. Detay sayfasında checklist olarak gösterilir."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="SSS"
|
||||
name="faq"
|
||||
defaultValue={faqToText(service?.faq)}
|
||||
rows={8}
|
||||
placeholder={
|
||||
"Soru 1?\nCevap 1 burada.\n---\nSoru 2?\nCevap 2 burada."
|
||||
}
|
||||
help="Her soru/cevap blokunu '---' ile ayırın. İlk satır soru, kalanı cevap."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Checkbox
|
||||
label="Öne çıkar (Anasayfada göster)"
|
||||
|
||||
@@ -12,5 +12,5 @@ export default async function EditProjectPage({
|
||||
const { id } = await params;
|
||||
const project = await getRow<ProjectRow>(TABLES.projects, id);
|
||||
if (!project) notFound();
|
||||
return <ProjectForm project={project} />;
|
||||
return await ProjectForm({ project });
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveProject } from "@/lib/admin-actions";
|
||||
import { listServices } from "@/lib/data";
|
||||
import type { ProjectRow } from "@/lib/types";
|
||||
|
||||
export function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
export async function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
const services = await listServices();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -26,6 +29,34 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
<Field label="Başlık" name="title" required defaultValue={project?.title} />
|
||||
<Field label="Slug" name="slug" defaultValue={project?.slug} />
|
||||
<Field label="Kategori" name="category" defaultValue={project?.category} />
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-[var(--navy)]">
|
||||
İlgili hizmet
|
||||
</span>
|
||||
<select
|
||||
name="service_slug"
|
||||
defaultValue={project?.service_slug ?? ""}
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
|
||||
>
|
||||
<option value="">— Yok —</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.slug} value={s.slug}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="mt-1 block text-xs text-[var(--muted)]">
|
||||
Bu projenin ait olduğu hizmet — detay sayfasında "ilgili projeler" olarak görünür.
|
||||
</span>
|
||||
</label>
|
||||
<Field label="Müşteri" name="client_name" defaultValue={project?.client_name} />
|
||||
<Field label="Sektör" name="industry" defaultValue={project?.industry} />
|
||||
<Field
|
||||
label="Süre"
|
||||
name="duration"
|
||||
defaultValue={project?.duration}
|
||||
placeholder="örn: 3 ay"
|
||||
/>
|
||||
<Field
|
||||
label="Yıl"
|
||||
name="year"
|
||||
@@ -33,7 +64,7 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
defaultValue={project?.year ?? new Date().getFullYear()}
|
||||
/>
|
||||
<Field
|
||||
label="Görsel URL"
|
||||
label="Kapak görseli URL"
|
||||
name="image_url"
|
||||
type="url"
|
||||
defaultValue={project?.image_url}
|
||||
@@ -52,15 +83,39 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
help="Virgülle ayırın."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<Textarea
|
||||
label="Açıklama"
|
||||
label="Kısa açıklama (kart için)"
|
||||
name="description"
|
||||
required
|
||||
defaultValue={project?.description}
|
||||
rows={6}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Vaka çalışması içeriği (Markdown)"
|
||||
name="content"
|
||||
defaultValue={project?.content}
|
||||
rows={12}
|
||||
placeholder={
|
||||
"## Müşteri\n\n## Problem\n\n## Çözüm\n\n## Sonuç"
|
||||
}
|
||||
help="Proje detay sayfasında uzun anlatım olarak gösterilir."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Galeri görselleri"
|
||||
name="gallery"
|
||||
defaultValue={project?.gallery?.join("\n")}
|
||||
rows={5}
|
||||
placeholder={
|
||||
"https://example.com/image1.jpg\nhttps://example.com/image2.jpg"
|
||||
}
|
||||
help="Her satıra bir URL. Medya kütüphanesinden URL'leri kopyalayın."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Checkbox
|
||||
label="Öne çıkar (Anasayfada göster)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ProjectForm } from "../form";
|
||||
|
||||
export default function NewProjectPage() {
|
||||
return <ProjectForm />;
|
||||
export default async function NewProjectPage() {
|
||||
return await ProjectForm({});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { FaqItem } from "@/lib/types";
|
||||
|
||||
export function FaqList({ items }: { items: FaqItem[] }) {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-[var(--border)] rounded-2xl border border-[var(--border)] bg-white">
|
||||
{items.map((it, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<details
|
||||
key={i}
|
||||
open={isOpen}
|
||||
onToggle={(e) => {
|
||||
if ((e.target as HTMLDetailsElement).open) setOpen(i);
|
||||
else if (isOpen) setOpen(null);
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]/60">
|
||||
<span>{it.q}</span>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-[var(--muted)] transition ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</summary>
|
||||
<div className="px-5 pb-5 text-sm leading-relaxed text-[var(--muted)]">
|
||||
{it.a}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setActive(null);
|
||||
if (e.key === "ArrowRight")
|
||||
setActive((i) => (i === null ? null : (i + 1) % images.length));
|
||||
if (e.key === "ArrowLeft")
|
||||
setActive((i) =>
|
||||
i === null ? null : (i - 1 + images.length) % images.length,
|
||||
);
|
||||
};
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [active, images.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={src + i}
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group relative aspect-video overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--navy-50)]"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={`${alt} — ${i + 1}`}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||
className="object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active !== null && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={() => setActive(null)}
|
||||
className="absolute right-4 top-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Önceki"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActive((i) =>
|
||||
i === null ? null : (i - 1 + images.length) % images.length,
|
||||
);
|
||||
}}
|
||||
className="absolute left-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sonraki"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActive((i) =>
|
||||
i === null ? null : (i + 1) % images.length,
|
||||
);
|
||||
}}
|
||||
className="absolute right-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative max-h-[90vh] max-w-6xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Image
|
||||
src={images[active]}
|
||||
alt={`${alt} — ${active + 1}`}
|
||||
width={1600}
|
||||
height={1200}
|
||||
className="max-h-[90vh] w-auto rounded-lg object-contain"
|
||||
/>
|
||||
<p className="mt-3 text-center text-xs text-white/60">
|
||||
{active + 1} / {images.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, ExternalLink } from "lucide-react";
|
||||
import type { ProjectRow } from "@/lib/types";
|
||||
|
||||
export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
|
||||
@@ -20,41 +21,47 @@ export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
|
||||
key={p.$id}
|
||||
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-xl"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
|
||||
{p.image_url ? (
|
||||
<Image
|
||||
src={p.image_url}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||
className="object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--navy)]/30">
|
||||
<span className="text-5xl font-bold">{p.title.charAt(0)}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.category && (
|
||||
<span className="absolute left-4 top-4 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-[var(--navy)] shadow-sm">
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/projeler/${p.slug}`} className="block">
|
||||
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
|
||||
{p.image_url ? (
|
||||
<Image
|
||||
src={p.image_url}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||
className="object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--navy)]/30">
|
||||
<span className="text-5xl font-bold">{p.title.charAt(0)}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.category && (
|
||||
<span className="absolute left-4 top-4 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-[var(--navy)] shadow-sm">
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-[var(--navy)]">
|
||||
{p.title}
|
||||
</h3>
|
||||
{p.live_url && (
|
||||
<Link href={`/projeler/${p.slug}`} className="block">
|
||||
<h3 className="text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
|
||||
{p.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{p.live_url ? (
|
||||
<a
|
||||
href={p.live_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Projeyi aç"
|
||||
aria-label="Projeyi canlı aç"
|
||||
className="text-[var(--sky-600)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ArrowUpRight className="size-5" />
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
) : (
|
||||
<ArrowUpRight className="size-5 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
@@ -18,24 +20,29 @@ export function ServicesGrid({ services }: { services: ServiceRow[] }) {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{items.map((s) => (
|
||||
<article
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/hizmetler/${s.slug}`}
|
||||
id={s.slug}
|
||||
className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-6 transition hover:border-[var(--sky)]/40 hover:shadow-lg hover:shadow-[var(--sky)]/10"
|
||||
>
|
||||
<div className="absolute -right-12 -top-12 size-32 rounded-full bg-[var(--sky-50)] opacity-0 transition group-hover:opacity-100" aria-hidden />
|
||||
<div
|
||||
className="absolute -right-12 -top-12 size-32 rounded-full bg-[var(--sky-50)] opacity-0 transition group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
<ArrowUpRight className="absolute right-5 top-5 size-4 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" />
|
||||
<div className="relative">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-[var(--navy-50)] text-[var(--navy)]">
|
||||
<Icon name={s.icon} className="size-6" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold text-[var(--navy)]">
|
||||
<h3 className="mt-5 text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
|
||||
{s.description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -129,6 +129,19 @@ export async function saveService(formData: FormData) {
|
||||
if (!description) throw new Error("Açıklama zorunlu");
|
||||
const slug = str(formData.get("slug")) || slugify(title);
|
||||
|
||||
// FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."}
|
||||
const faqRaw = String(formData.get("faq") ?? "");
|
||||
const faq = faqRaw
|
||||
.split("\n---\n")
|
||||
.map((block) => {
|
||||
const lines = block.trim().split("\n");
|
||||
const q = lines[0]?.trim();
|
||||
const a = lines.slice(1).join("\n").trim();
|
||||
if (!q || !a) return null;
|
||||
return JSON.stringify({ q, a });
|
||||
})
|
||||
.filter((x): x is string => x !== null);
|
||||
|
||||
const data = {
|
||||
slug,
|
||||
title,
|
||||
@@ -136,6 +149,10 @@ export async function saveService(formData: FormData) {
|
||||
icon: str(formData.get("icon")),
|
||||
order: num(formData.get("order")) ?? 0,
|
||||
featured: bool(formData.get("featured")),
|
||||
content: str(formData.get("content")),
|
||||
features: strArr(formData.get("features"))?.filter(Boolean) ?? null,
|
||||
faq: faq.length > 0 ? faq : null,
|
||||
hero_image: str(formData.get("hero_image")),
|
||||
};
|
||||
if (id) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, data, secret);
|
||||
@@ -172,6 +189,13 @@ export async function saveProject(formData: FormData) {
|
||||
const description = str(formData.get("description"));
|
||||
if (!description) throw new Error("Açıklama zorunlu");
|
||||
|
||||
// Gallery: one URL per line
|
||||
const galleryRaw = String(formData.get("gallery") ?? "");
|
||||
const gallery = galleryRaw
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const data = {
|
||||
slug,
|
||||
title,
|
||||
@@ -182,6 +206,12 @@ export async function saveProject(formData: FormData) {
|
||||
technologies: strArr(formData.get("technologies")),
|
||||
year: num(formData.get("year")),
|
||||
featured: bool(formData.get("featured")),
|
||||
gallery: gallery.length > 0 ? gallery : null,
|
||||
content: str(formData.get("content")),
|
||||
client_name: str(formData.get("client_name")),
|
||||
industry: str(formData.get("industry")),
|
||||
duration: str(formData.get("duration")),
|
||||
service_slug: str(formData.get("service_slug")),
|
||||
};
|
||||
|
||||
if (id) {
|
||||
|
||||
+22
-1
@@ -41,12 +41,33 @@ export async function listServices(opts?: { featured?: boolean }) {
|
||||
return safeList<ServiceRow>(TABLES.services, q);
|
||||
}
|
||||
|
||||
export async function listProjects(opts?: { featured?: boolean; limit?: number }) {
|
||||
export async function listProjects(opts?: {
|
||||
featured?: boolean;
|
||||
limit?: number;
|
||||
serviceSlug?: string;
|
||||
}) {
|
||||
const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)];
|
||||
if (opts?.featured) q.unshift(Q.equal("featured", true));
|
||||
if (opts?.serviceSlug) q.unshift(Q.equal("service_slug", opts.serviceSlug));
|
||||
return safeList<ProjectRow>(TABLES.projects, q);
|
||||
}
|
||||
|
||||
export async function getServiceBySlug(slug: string): Promise<ServiceRow | null> {
|
||||
const res = await safeList<ServiceRow>(TABLES.services, [
|
||||
Q.equal("slug", slug),
|
||||
Q.limit(1),
|
||||
]);
|
||||
return res[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getProjectBySlug(slug: string): Promise<ProjectRow | null> {
|
||||
const res = await safeList<ProjectRow>(TABLES.projects, [
|
||||
Q.equal("slug", slug),
|
||||
Q.limit(1),
|
||||
]);
|
||||
return res[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listPublishedPosts(opts?: { limit?: number }) {
|
||||
return safeList<BlogPostRow>(TABLES.blogPosts, [
|
||||
Q.equal("status", "published"),
|
||||
|
||||
@@ -9,6 +9,15 @@ export interface ServiceRow extends AwRow {
|
||||
icon?: string | null;
|
||||
order?: number | null;
|
||||
featured?: boolean | null;
|
||||
content?: string | null;
|
||||
features?: string[] | null;
|
||||
faq?: string[] | null; // each item is JSON: {"q":"...","a":"..."}
|
||||
hero_image?: string | null;
|
||||
}
|
||||
|
||||
export interface FaqItem {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
export interface ProjectRow extends AwRow {
|
||||
@@ -21,6 +30,12 @@ export interface ProjectRow extends AwRow {
|
||||
technologies?: string[] | null;
|
||||
year?: number | null;
|
||||
featured?: boolean | null;
|
||||
gallery?: string[] | null;
|
||||
content?: string | null;
|
||||
client_name?: string | null;
|
||||
industry?: string | null;
|
||||
duration?: string | null;
|
||||
service_slug?: string | null;
|
||||
}
|
||||
|
||||
export interface BlogPostRow extends AwRow {
|
||||
|
||||
Reference in New Issue
Block a user