feat: admin paneli + blog + testimonials + SEO yöneticisi
Backend altyapısı: - 4 yeni Appwrite tablosu: blog_posts, testimonials, seo_pages, seo_settings - Appwrite Storage bucket: kovak-yazilim-media (görsel yüklemeleri) - Appwrite Auth ile session cookie tabanlı koruma Admin paneli (/admin): - Login akışı (email/password) + protected layout - Dashboard: sayım kartları + hızlı aksiyonlar - Blog CRUD: markdown content, kapak görseli, draft/published, SEO alanları - Services CRUD: lucide ikon seçici - Projects CRUD: teknoloji etiketleri, live URL - Testimonials CRUD: puanlama - SEO yöneticisi: global ayarlar + sayfa bazlı override - Mesaj inbox: status filtreleme + güncelleme - Medya kütüphanesi: Appwrite Storage upload/delete Public: - /blog ve /blog/[slug] sayfaları (markdown render) - Anasayfaya Testimonials bölümü - Tüm public sayfalarda generateMetadata + seo_pages override - Header'a Blog linki Route yapısı: - app/(site)/ — public site, Header/Footer ortak - app/admin/login — auth dışı - app/admin/(protected)/ — requireUser() korumalı 23 route üretiliyor, public static, admin dynamic.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Calendar } from "lucide-react";
|
||||
import { marked } from "marked";
|
||||
import { getPostBySlug } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) return { title: "Yazı bulunamadı" };
|
||||
return buildMetadata(`/blog/${slug}`, {
|
||||
title: post.seo_title || post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
openGraph: {
|
||||
title: post.seo_title || post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
images: post.seo_image || post.cover_image ? [{ url: (post.seo_image || post.cover_image) as string }] : undefined,
|
||||
type: "article",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post || post.status !== "published") notFound();
|
||||
|
||||
const html = post.content ? marked.parse(post.content, { async: false }) as string : "";
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-20">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> Tüm yazılar
|
||||
</Link>
|
||||
|
||||
<header className="mt-6 border-b border-[var(--border)] pb-8">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded-full bg-[var(--sky-50)] px-2.5 py-1 text-xs text-[var(--sky-600)]"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="mt-4 text-3xl font-bold leading-tight tracking-tight text-[var(--navy)] sm:text-4xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.excerpt && (
|
||||
<p className="mt-4 text-lg leading-relaxed text-[var(--muted)]">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||
{post.author && <span>{post.author}</span>}
|
||||
{post.author && post.published_at && <span>•</span>}
|
||||
{post.published_at && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{new Date(post.published_at).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{post.cover_image && (
|
||||
<div className="relative mt-8 aspect-video overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 768px, 100vw"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="prose prose-lg mt-10 max-w-none text-[var(--foreground)]"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowRight, Calendar } from "lucide-react";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { listPublishedPosts } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/blog", {
|
||||
title: "Blog",
|
||||
description: "Yazılım, web tasarım, SEO ve dijital pazarlama üzerine yazılar.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogIndex() {
|
||||
const posts = await listPublishedPosts();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-20">
|
||||
<SectionTitle
|
||||
eyebrow="Blog"
|
||||
title="Yazılım, tasarım ve büyüme üzerine"
|
||||
description="Sektörden notlar, vaka çalışmaları ve teknik rehberler."
|
||||
/>
|
||||
|
||||
<div className="mt-14">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--navy-50)]/40 p-12 text-center">
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
Henüz yayınlanmış yazı yok.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((p) => (
|
||||
<article
|
||||
key={p.$id}
|
||||
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg"
|
||||
>
|
||||
<Link href={`/blog/${p.slug}`}>
|
||||
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
|
||||
{p.cover_image ? (
|
||||
<Image
|
||||
src={p.cover_image}
|
||||
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-3xl font-bold text-[var(--navy)]/30">
|
||||
{p.title.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="flex items-center gap-1.5 text-xs text-[var(--muted)]">
|
||||
<Calendar className="size-3.5" />
|
||||
{p.published_at
|
||||
? new Date(p.published_at).toLocaleDateString("tr-TR")
|
||||
: "—"}
|
||||
</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
|
||||
{p.title}
|
||||
</h3>
|
||||
{p.excerpt && (
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
|
||||
{p.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--sky-600)]">
|
||||
Devamını oku <ArrowRight className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hakkımızda",
|
||||
description:
|
||||
"Kovak Yazılım, Kocaeli merkezli bir teknoloji ajansıdır. Web, mobil ve CRM çözümleri üretir.",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/hakkimizda", {
|
||||
title: "Hakkımızda",
|
||||
description:
|
||||
"Kovak Yazılım, Kocaeli merkezli bir teknoloji ajansıdır. Web, mobil ve CRM çözümleri üretir.",
|
||||
});
|
||||
}
|
||||
|
||||
const values = [
|
||||
{
|
||||
@@ -2,12 +2,15 @@ import type { Metadata } from "next";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { ServicesGrid } from "@/components/services-grid";
|
||||
import { listServices } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hizmetler",
|
||||
description:
|
||||
"Web tasarım, e-ticaret, mobil uygulama, yazılım geliştirme, CRM ve dijital pazarlama hizmetleri.",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/hizmetler", {
|
||||
title: "Hizmetler",
|
||||
description:
|
||||
"Web tasarım, e-ticaret, mobil uygulama, yazılım geliştirme, CRM ve dijital pazarlama hizmetleri.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ServicesPage() {
|
||||
const services = await listServices();
|
||||
@@ -3,12 +3,15 @@ import { Mail, MapPin, Phone, Clock } from "lucide-react";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { ContactForm } from "@/components/contact-form";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İletişim",
|
||||
description:
|
||||
"Projeniz hakkında konuşmak için bize ulaşın. İzmit Sanayi Sitesi, Kocaeli.",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/iletisim", {
|
||||
title: "İletişim",
|
||||
description:
|
||||
"Projeniz hakkında konuşmak için bize ulaşın. İzmit Sanayi Sitesi, Kocaeli.",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
export default function SiteLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { ServicesGrid } from "@/components/services-grid";
|
||||
import { ProjectsGrid } from "@/components/projects-grid";
|
||||
import { listProjects, listServices } from "@/lib/data";
|
||||
import { TestimonialsCarousel } from "@/components/testimonials";
|
||||
import { listProjects, listServices, listTestimonials } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/");
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const [services, projects] = await Promise.all([
|
||||
const [services, projects, testimonials] = await Promise.all([
|
||||
listServices({ featured: true }),
|
||||
listProjects({ featured: true, limit: 6 }),
|
||||
listTestimonials({ featured: true }),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -51,6 +59,21 @@ export default async function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{testimonials.length > 0 && (
|
||||
<section className="border-y border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<SectionTitle
|
||||
eyebrow="Referanslar"
|
||||
title="Müşterilerimiz ne diyor?"
|
||||
description="Birlikte çalıştığımız markalardan geri bildirimler."
|
||||
/>
|
||||
<div className="mt-12">
|
||||
<TestimonialsCarousel items={testimonials} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="relative overflow-hidden bg-[var(--navy)] py-20 text-white">
|
||||
<div className="absolute -left-20 top-0 size-96 rounded-full bg-[var(--sky)]/20 blur-3xl" aria-hidden />
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
@@ -2,11 +2,14 @@ import type { Metadata } from "next";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { ProjectsGrid } from "@/components/projects-grid";
|
||||
import { listProjects } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Projeler",
|
||||
description: "Tamamladığımız web, mobil ve CRM projelerinden seçkiler.",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/projeler", {
|
||||
title: "Projeler",
|
||||
description: "Tamamladığımız web, mobil ve CRM projelerinden seçkiler.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const projects = await listProjects();
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { ID } from "node-appwrite";
|
||||
import { serverTablesDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
|
||||
import { adminDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
|
||||
|
||||
export type ContactFormState = {
|
||||
ok: boolean;
|
||||
@@ -34,7 +34,7 @@ export async function submitContact(
|
||||
}
|
||||
|
||||
try {
|
||||
await serverTablesDB.createRow({
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.contactMessages,
|
||||
rowId: ID.unique(),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRow } from "@/lib/data";
|
||||
import { TABLES } from "@/lib/appwrite-server";
|
||||
import type { BlogPostRow } from "@/lib/types";
|
||||
import { BlogForm } from "../../form";
|
||||
|
||||
export default async function EditBlogPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const post = await getRow<BlogPostRow>(TABLES.blogPosts, id);
|
||||
if (!post) notFound();
|
||||
return <BlogForm post={post} />;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Field,
|
||||
FormActions,
|
||||
FormShell,
|
||||
GhostLink,
|
||||
PageHeader,
|
||||
PrimaryButton,
|
||||
Select,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveBlogPost } from "@/lib/admin-actions";
|
||||
import type { BlogPostRow } from "@/lib/types";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
export function BlogForm({ post }: { post?: BlogPostRow }) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={post ? "Yazıyı düzenle" : "Yeni yazı"}
|
||||
backHref="/admin/blog"
|
||||
description="Markdown formatında içerik yazabilirsiniz."
|
||||
/>
|
||||
|
||||
<form action={saveBlogPost}>
|
||||
{post && <input type="hidden" name="id" value={post.$id} />}
|
||||
|
||||
<FormShell>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Field
|
||||
label="Başlık"
|
||||
name="title"
|
||||
required
|
||||
defaultValue={post?.title}
|
||||
placeholder="Yazı başlığı"
|
||||
/>
|
||||
<Field
|
||||
label="Slug"
|
||||
name="slug"
|
||||
defaultValue={post?.slug}
|
||||
placeholder="otomatik-uretilir"
|
||||
help="Boş bırakırsanız başlıktan üretilir."
|
||||
/>
|
||||
<Field label="Yazar" name="author" defaultValue={post?.author} />
|
||||
<Select
|
||||
label="Durum"
|
||||
name="status"
|
||||
defaultValue={post?.status ?? "draft"}
|
||||
options={[
|
||||
{ value: "draft", label: "Taslak" },
|
||||
{ value: "published", label: "Yayında" },
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
label="Yayın tarihi (ISO)"
|
||||
name="published_at"
|
||||
type="datetime-local"
|
||||
defaultValue={post?.published_at?.slice(0, 16)}
|
||||
help="Boş bırakırsanız yayına alındığı an kullanılır."
|
||||
/>
|
||||
<Field
|
||||
label="Etiketler"
|
||||
name="tags"
|
||||
defaultValue={post?.tags?.join(", ")}
|
||||
placeholder="seo, web tasarım, kocaeli"
|
||||
help="Virgülle ayırın."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<Textarea
|
||||
label="Özet"
|
||||
name="excerpt"
|
||||
defaultValue={post?.excerpt}
|
||||
rows={3}
|
||||
placeholder="Liste/kart görünümünde gösterilecek kısa özet"
|
||||
/>
|
||||
<Textarea
|
||||
label="İçerik (Markdown)"
|
||||
name="content"
|
||||
defaultValue={post?.content}
|
||||
rows={14}
|
||||
placeholder={"# Başlık\n\nMarkdown desteklenir…"}
|
||||
/>
|
||||
<Field
|
||||
label="Kapak görseli URL"
|
||||
name="cover_image"
|
||||
type="url"
|
||||
defaultValue={post?.cover_image}
|
||||
placeholder="https://…"
|
||||
help="Medya kütüphanesinden bir görselin view URL'ini yapıştırın."
|
||||
/>
|
||||
<Field
|
||||
label="Kapak file_id"
|
||||
name="cover_file_id"
|
||||
defaultValue={post?.cover_file_id}
|
||||
help="(opsiyonel) Appwrite storage file ID'si"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-8 text-sm font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
SEO
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-5 md:grid-cols-2">
|
||||
<Field
|
||||
label="SEO başlığı"
|
||||
name="seo_title"
|
||||
defaultValue={post?.seo_title}
|
||||
help="Boş bırakırsanız yazı başlığı kullanılır."
|
||||
/>
|
||||
<Field
|
||||
label="SEO OG görseli"
|
||||
name="seo_image"
|
||||
type="url"
|
||||
defaultValue={post?.seo_image}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="SEO açıklaması"
|
||||
name="seo_description"
|
||||
defaultValue={post?.seo_description}
|
||||
rows={2}
|
||||
help="150–160 karakter ideal."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormActions>
|
||||
<GhostLink href="/admin/blog">İptal</GhostLink>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BlogForm } from "../form";
|
||||
|
||||
export default function NewBlogPostPage() {
|
||||
return <BlogForm />;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import { Plus, Edit, ExternalLink } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { listAllPosts } from "@/lib/data";
|
||||
import { deleteBlogPost } from "@/lib/admin-actions";
|
||||
|
||||
export default async function BlogListPage() {
|
||||
const posts = await listAllPosts();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Blog"
|
||||
description="Yazılarınızı yönetin ve yayınlayın."
|
||||
action={
|
||||
<Link
|
||||
href="/admin/blog/new"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
<Plus className="size-4" /> Yeni yazı
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Başlık</th>
|
||||
<th className="px-4 py-3 text-left">Slug</th>
|
||||
<th className="px-4 py-3 text-left">Durum</th>
|
||||
<th className="px-4 py-3 text-left">Yayın</th>
|
||||
<th className="px-4 py-3 text-right">İşlem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-[var(--muted)]">
|
||||
Henüz yazı yok. İlk yazınızı oluşturun.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{posts.map((p) => (
|
||||
<tr key={p.$id} className="border-t border-[var(--border)]">
|
||||
<td className="px-4 py-3 font-medium text-[var(--navy)]">{p.title}</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">/{p.slug}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={p.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">
|
||||
{p.published_at
|
||||
? new Date(p.published_at).toLocaleDateString("tr-TR")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{p.status === "published" && (
|
||||
<Link
|
||||
href={`/blog/${p.slug}`}
|
||||
target="_blank"
|
||||
className="rounded-md border border-[var(--border)] p-1.5 text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/blog/${p.$id}/edit`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
|
||||
>
|
||||
<Edit className="size-3.5" />
|
||||
Düzenle
|
||||
</Link>
|
||||
<form action={deleteBlogPost}>
|
||||
<input type="hidden" name="id" value={p.$id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: string | null }) {
|
||||
const isPub = status === "published";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
isPub
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-amber-100 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
{isPub ? "Yayında" : "Taslak"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRow } from "@/lib/data";
|
||||
import { TABLES } from "@/lib/appwrite-server";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
import { ServiceForm } from "../../form";
|
||||
|
||||
export default async function EditServicePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const service = await getRow<ServiceRow>(TABLES.services, id);
|
||||
if (!service) notFound();
|
||||
return <ServiceForm service={service} />;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
Checkbox,
|
||||
Field,
|
||||
FormActions,
|
||||
FormShell,
|
||||
GhostLink,
|
||||
PageHeader,
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveService } from "@/lib/admin-actions";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
|
||||
const ICON_OPTIONS = [
|
||||
"Globe",
|
||||
"ShoppingCart",
|
||||
"Smartphone",
|
||||
"Code2",
|
||||
"Users",
|
||||
"TrendingUp",
|
||||
"Share2",
|
||||
"Megaphone",
|
||||
"Layers",
|
||||
];
|
||||
|
||||
export function ServiceForm({ service }: { service?: ServiceRow }) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={service ? "Hizmeti düzenle" : "Yeni hizmet"}
|
||||
backHref="/admin/hizmetler"
|
||||
/>
|
||||
<form action={saveService}>
|
||||
{service && <input type="hidden" name="id" value={service.$id} />}
|
||||
<FormShell>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Field label="Başlık" name="title" required defaultValue={service?.title} />
|
||||
<Field label="Slug" name="slug" defaultValue={service?.slug} />
|
||||
<Field
|
||||
label="Sıra"
|
||||
name="order"
|
||||
type="number"
|
||||
defaultValue={service?.order ?? 0}
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-[var(--navy)]">İkon</span>
|
||||
<select
|
||||
name="icon"
|
||||
defaultValue={service?.icon ?? "Layers"}
|
||||
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"
|
||||
>
|
||||
{ICON_OPTIONS.map((i) => (
|
||||
<option key={i} value={i}>
|
||||
{i}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="mt-1 block text-xs text-[var(--muted)]">
|
||||
Lucide icon adı.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Açıklama"
|
||||
name="description"
|
||||
required
|
||||
defaultValue={service?.description}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Checkbox
|
||||
label="Öne çıkar (Anasayfada göster)"
|
||||
name="featured"
|
||||
defaultChecked={service?.featured ?? false}
|
||||
/>
|
||||
</div>
|
||||
<FormActions>
|
||||
<GhostLink href="/admin/hizmetler">İptal</GhostLink>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ServiceForm } from "../form";
|
||||
|
||||
export default function NewServicePage() {
|
||||
return <ServiceForm />;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
import { Plus, Edit } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { listServices } from "@/lib/data";
|
||||
import { deleteService } from "@/lib/admin-actions";
|
||||
|
||||
export default async function ServicesAdminPage() {
|
||||
const services = await listServices();
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Hizmetler"
|
||||
description="Anasayfa ve /hizmetler sayfasında gösterilen hizmet kartları."
|
||||
action={
|
||||
<Link
|
||||
href="/admin/hizmetler/new"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
<Plus className="size-4" /> Yeni hizmet
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Sıra</th>
|
||||
<th className="px-4 py-3 text-left">Başlık</th>
|
||||
<th className="px-4 py-3 text-left">Slug</th>
|
||||
<th className="px-4 py-3 text-left">İkon</th>
|
||||
<th className="px-4 py-3 text-left">Öne çıkan</th>
|
||||
<th className="px-4 py-3 text-right">İşlem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-[var(--muted)]">
|
||||
Hizmet eklenmemiş.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{services.map((s) => (
|
||||
<tr key={s.$id} className="border-t border-[var(--border)]">
|
||||
<td className="px-4 py-3 text-[var(--muted)]">{s.order ?? 0}</td>
|
||||
<td className="px-4 py-3 font-medium text-[var(--navy)]">{s.title}</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">{s.slug}</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">{s.icon ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.featured ? (
|
||||
<span className="rounded-full bg-[var(--sky-50)] px-2 py-0.5 text-xs text-[var(--sky-600)]">
|
||||
Öne çıkan
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/hizmetler/${s.$id}/edit`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
|
||||
>
|
||||
<Edit className="size-3.5" /> Düzenle
|
||||
</Link>
|
||||
<form action={deleteService}>
|
||||
<input type="hidden" name="id" value={s.$id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Link from "next/link";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { listMessages } from "@/lib/data";
|
||||
import { deleteMessage, updateMessageStatus } from "@/lib/admin-actions";
|
||||
import type { ContactMessageRow } from "@/lib/types";
|
||||
|
||||
const FILTERS = [
|
||||
{ value: "", label: "Tümü" },
|
||||
{ value: "new", label: "Yeni" },
|
||||
{ value: "read", label: "Okundu" },
|
||||
{ value: "replied", label: "Yanıtlandı" },
|
||||
{ value: "archived", label: "Arşiv" },
|
||||
];
|
||||
|
||||
export default async function MessagesAdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ filter?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const filter = sp.filter as ContactMessageRow["status"] | undefined;
|
||||
const messages = await listMessages(filter || undefined);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="İletişim mesajları"
|
||||
description="Forma gelen mesajları yönetin."
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{FILTERS.map((f) => {
|
||||
const active = (filter ?? "") === f.value;
|
||||
return (
|
||||
<Link
|
||||
key={f.value}
|
||||
href={`/admin/iletisim${f.value ? `?filter=${f.value}` : ""}`}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${
|
||||
active
|
||||
? "border-[var(--navy)] bg-[var(--navy)] text-white"
|
||||
: "border-[var(--border)] bg-white text-[var(--muted)] hover:border-[var(--navy)]"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-white p-12 text-center text-sm text-[var(--muted)]">
|
||||
Bu filtrede mesaj yok.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<article
|
||||
key={m.$id}
|
||||
className="rounded-2xl border border-[var(--border)] bg-white p-5"
|
||||
>
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-[var(--navy)]">{m.name}</p>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[var(--muted)]">
|
||||
<a href={`mailto:${m.email}`} className="inline-flex items-center gap-1 hover:text-[var(--navy)]">
|
||||
<Mail className="size-3" />
|
||||
{m.email}
|
||||
</a>
|
||||
{m.phone && (
|
||||
<a href={`tel:${m.phone}`} className="inline-flex items-center gap-1 hover:text-[var(--navy)]">
|
||||
<Phone className="size-3" />
|
||||
{m.phone}
|
||||
</a>
|
||||
)}
|
||||
<span>{new Date(m.$createdAt).toLocaleString("tr-TR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<form action={updateMessageStatus} className="flex items-center gap-1">
|
||||
<input type="hidden" name="id" value={m.$id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={m.status ?? "new"}
|
||||
className="rounded-md border border-[var(--border)] bg-white px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="new">Yeni</option>
|
||||
<option value="read">Okundu</option>
|
||||
<option value="replied">Yanıtlandı</option>
|
||||
<option value="archived">Arşiv</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-[var(--navy)] px-2.5 py-1 text-xs font-medium text-white hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
Güncelle
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteMessage}>
|
||||
<input type="hidden" name="id" value={m.$id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{m.subject && (
|
||||
<p className="mt-3 text-sm font-medium text-[var(--navy)]">
|
||||
{m.subject}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
|
||||
{m.message}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: string | null }) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
new: { label: "Yeni", cls: "bg-red-100 text-red-800" },
|
||||
read: { label: "Okundu", cls: "bg-amber-100 text-amber-800" },
|
||||
replied: { label: "Yanıtlandı", cls: "bg-green-100 text-green-800" },
|
||||
archived: { label: "Arşiv", cls: "bg-gray-100 text-gray-700" },
|
||||
};
|
||||
const meta = map[status ?? "new"] ?? map.new;
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${meta.cls}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { AdminSidebar } from "@/components/admin/sidebar";
|
||||
import { AdminTopbar } from "@/components/admin/topbar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "Yönetim", template: "%s | Yönetim Paneli" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function ProtectedAdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = await requireUser();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[var(--navy-50)]/30">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1">
|
||||
<AdminTopbar email={user.email} name={user.name} />
|
||||
<div className="px-6 py-8 md:px-10">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { adminStorage, MEDIA_BUCKET_ID } from "@/lib/appwrite-server";
|
||||
import { Query } from "node-appwrite";
|
||||
import { Upload } from "lucide-react";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { uploadMedia, deleteMediaFile } from "@/lib/admin-actions";
|
||||
|
||||
async function listFiles() {
|
||||
try {
|
||||
const res = await adminStorage.listFiles({
|
||||
bucketId: MEDIA_BUCKET_ID,
|
||||
queries: [Query.orderDesc("$createdAt"), Query.limit(100)],
|
||||
});
|
||||
return res.files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function fileViewUrl(id: string) {
|
||||
return `${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT}/storage/buckets/${MEDIA_BUCKET_ID}/files/${id}/view?project=${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID}`;
|
||||
}
|
||||
|
||||
export default async function MediaPage() {
|
||||
const files = await listFiles();
|
||||
|
||||
async function deleteAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = String(formData.get("id"));
|
||||
await deleteMediaFile(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Medya kütüphanesi"
|
||||
description="Görselleri yükleyin ve URL'lerini kopyalayın."
|
||||
/>
|
||||
|
||||
<form
|
||||
action={uploadMedia}
|
||||
encType="multipart/form-data"
|
||||
className="mt-6 rounded-2xl border border-dashed border-[var(--border)] bg-white p-6"
|
||||
>
|
||||
<label className="flex flex-col items-center gap-3 text-center">
|
||||
<Upload className="size-8 text-[var(--sky-600)]" />
|
||||
<span className="text-sm font-medium text-[var(--navy)]">
|
||||
Görsel yüklemek için dosya seçin (max 10 MB)
|
||||
</span>
|
||||
<input
|
||||
name="file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
required
|
||||
className="block max-w-md text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-[var(--navy)] px-5 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
Yükle
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{files.length === 0 && (
|
||||
<div className="col-span-full rounded-2xl border border-dashed border-[var(--border)] bg-white p-12 text-center text-sm text-[var(--muted)]">
|
||||
Henüz görsel yüklenmemiş.
|
||||
</div>
|
||||
)}
|
||||
{files.map((f) => {
|
||||
const url = fileViewUrl(f.$id);
|
||||
return (
|
||||
<div
|
||||
key={f.$id}
|
||||
className="overflow-hidden rounded-xl border border-[var(--border)] bg-white"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={f.name}
|
||||
className="aspect-square w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<p className="truncate text-xs font-medium text-[var(--navy)]">
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--muted)]">
|
||||
{(f.sizeOriginal / 1024).toFixed(0)} KB
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-xs text-[var(--sky-600)] hover:text-[var(--navy)]"
|
||||
>
|
||||
Aç ↗
|
||||
</a>
|
||||
<form action={deleteAction}>
|
||||
<input type="hidden" name="id" value={f.$id} />
|
||||
<DeleteButton label="Sil" />
|
||||
</form>
|
||||
</div>
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
className="mt-2 w-full truncate rounded-md border border-[var(--border)] bg-[var(--navy-50)] px-2 py-1 text-[10px] text-[var(--muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { adminDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
async function safeCount(tableId: string, queries: string[] = []) {
|
||||
try {
|
||||
const res = await adminDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId,
|
||||
queries: [...queries, Query.limit(1)],
|
||||
});
|
||||
return res.total ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [posts, drafts, services, projects, testimonials, newMessages, totalMessages] =
|
||||
await Promise.all([
|
||||
safeCount(TABLES.blogPosts, [Query.equal("status", "published")]),
|
||||
safeCount(TABLES.blogPosts, [Query.equal("status", "draft")]),
|
||||
safeCount(TABLES.services),
|
||||
safeCount(TABLES.projects),
|
||||
safeCount(TABLES.testimonials),
|
||||
safeCount(TABLES.contactMessages, [Query.equal("status", "new")]),
|
||||
safeCount(TABLES.contactMessages),
|
||||
]);
|
||||
|
||||
const cards = [
|
||||
{ label: "Yayında blog", value: posts, href: "/admin/blog", accent: "navy" },
|
||||
{ label: "Taslak blog", value: drafts, href: "/admin/blog", accent: "amber" },
|
||||
{ label: "Hizmet", value: services, href: "/admin/hizmetler", accent: "navy" },
|
||||
{ label: "Proje", value: projects, href: "/admin/projeler", accent: "navy" },
|
||||
{ label: "Referans", value: testimonials, href: "/admin/referanslar", accent: "navy" },
|
||||
{ label: "Yeni mesaj", value: newMessages, href: "/admin/iletisim?filter=new", accent: "red" },
|
||||
{ label: "Toplam mesaj", value: totalMessages, href: "/admin/iletisim", accent: "navy" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-[var(--navy)]">Pano</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Site içeriklerini buradan yönetin.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((c) => (
|
||||
<Link
|
||||
key={c.label}
|
||||
href={c.href}
|
||||
className="group rounded-2xl border border-[var(--border)] bg-white p-5 transition hover:border-[var(--sky)]/40 hover:shadow-md"
|
||||
>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--muted)]">
|
||||
{c.label}
|
||||
</p>
|
||||
<div className="mt-2 flex items-end justify-between">
|
||||
<p
|
||||
className={`text-3xl font-bold ${
|
||||
c.accent === "red"
|
||||
? "text-red-600"
|
||||
: c.accent === "amber"
|
||||
? "text-amber-600"
|
||||
: "text-[var(--navy)]"
|
||||
}`}
|
||||
>
|
||||
{c.value}
|
||||
</p>
|
||||
<ArrowRight className="size-4 -translate-x-2 text-[var(--muted)] opacity-0 transition group-hover:translate-x-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-10 grid gap-6 lg:grid-cols-2">
|
||||
<QuickActions />
|
||||
<RecentLinks />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActions() {
|
||||
const actions = [
|
||||
{ href: "/admin/blog/new", label: "Yeni blog yazısı" },
|
||||
{ href: "/admin/projeler/new", label: "Yeni proje ekle" },
|
||||
{ href: "/admin/referanslar/new", label: "Yeni referans" },
|
||||
{ href: "/admin/seo", label: "SEO ayarları" },
|
||||
];
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-[var(--navy)]">Hızlı aksiyonlar</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{actions.map((a) => (
|
||||
<li key={a.href}>
|
||||
<Link
|
||||
href={a.href}
|
||||
className="flex items-center justify-between rounded-lg border border-[var(--border)] px-3 py-2 text-sm text-[var(--navy)] transition hover:border-[var(--sky)]"
|
||||
>
|
||||
{a.label}
|
||||
<ArrowRight className="size-4 text-[var(--sky-600)]" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentLinks() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-[var(--navy)]">Kısayollar</h2>
|
||||
<ul className="mt-4 space-y-1 text-sm text-[var(--muted)]">
|
||||
<li>• <Link href="/" target="_blank" className="hover:text-[var(--navy)]">Siteyi yeni sekmede aç</Link></li>
|
||||
<li>• <Link href="/admin/medya" className="hover:text-[var(--navy)]">Medya kütüphanesi</Link></li>
|
||||
<li>• <Link href="/admin/seo" className="hover:text-[var(--navy)]">SEO yönetimi</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRow } from "@/lib/data";
|
||||
import { TABLES } from "@/lib/appwrite-server";
|
||||
import type { ProjectRow } from "@/lib/types";
|
||||
import { ProjectForm } from "../../form";
|
||||
|
||||
export default async function EditProjectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const project = await getRow<ProjectRow>(TABLES.projects, id);
|
||||
if (!project) notFound();
|
||||
return <ProjectForm project={project} />;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
Checkbox,
|
||||
Field,
|
||||
FormActions,
|
||||
FormShell,
|
||||
GhostLink,
|
||||
PageHeader,
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveProject } from "@/lib/admin-actions";
|
||||
import type { ProjectRow } from "@/lib/types";
|
||||
|
||||
export function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={project ? "Projeyi düzenle" : "Yeni proje"}
|
||||
backHref="/admin/projeler"
|
||||
/>
|
||||
<form action={saveProject}>
|
||||
{project && <input type="hidden" name="id" value={project.$id} />}
|
||||
<FormShell>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<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} />
|
||||
<Field
|
||||
label="Yıl"
|
||||
name="year"
|
||||
type="number"
|
||||
defaultValue={project?.year ?? new Date().getFullYear()}
|
||||
/>
|
||||
<Field
|
||||
label="Görsel URL"
|
||||
name="image_url"
|
||||
type="url"
|
||||
defaultValue={project?.image_url}
|
||||
/>
|
||||
<Field
|
||||
label="Canlı URL"
|
||||
name="live_url"
|
||||
type="url"
|
||||
defaultValue={project?.live_url}
|
||||
/>
|
||||
<Field
|
||||
label="Teknolojiler"
|
||||
name="technologies"
|
||||
defaultValue={project?.technologies?.join(", ")}
|
||||
placeholder="Next.js, Appwrite, Tailwind"
|
||||
help="Virgülle ayırın."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Açıklama"
|
||||
name="description"
|
||||
required
|
||||
defaultValue={project?.description}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Checkbox
|
||||
label="Öne çıkar (Anasayfada göster)"
|
||||
name="featured"
|
||||
defaultChecked={project?.featured ?? false}
|
||||
/>
|
||||
</div>
|
||||
<FormActions>
|
||||
<GhostLink href="/admin/projeler">İptal</GhostLink>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProjectForm } from "../form";
|
||||
|
||||
export default function NewProjectPage() {
|
||||
return <ProjectForm />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import { Plus, Edit, ExternalLink } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { listProjects } from "@/lib/data";
|
||||
import { deleteProject } from "@/lib/admin-actions";
|
||||
|
||||
export default async function ProjectsAdminPage() {
|
||||
const projects = await listProjects();
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Projeler"
|
||||
description="Portföyünüzdeki çalışmaları yönetin."
|
||||
action={
|
||||
<Link
|
||||
href="/admin/projeler/new"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
<Plus className="size-4" /> Yeni proje
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Başlık</th>
|
||||
<th className="px-4 py-3 text-left">Kategori</th>
|
||||
<th className="px-4 py-3 text-left">Yıl</th>
|
||||
<th className="px-4 py-3 text-left">Öne çıkan</th>
|
||||
<th className="px-4 py-3 text-right">İşlem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-[var(--muted)]">
|
||||
Henüz proje yok.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{projects.map((p) => (
|
||||
<tr key={p.$id} className="border-t border-[var(--border)]">
|
||||
<td className="px-4 py-3 font-medium text-[var(--navy)]">{p.title}</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">{p.category ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">{p.year ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.featured ? (
|
||||
<span className="rounded-full bg-[var(--sky-50)] px-2 py-0.5 text-xs text-[var(--sky-600)]">
|
||||
Öne çıkan
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{p.live_url && (
|
||||
<a
|
||||
href={p.live_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="rounded-md border border-[var(--border)] p-1.5 text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/projeler/${p.$id}/edit`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
|
||||
>
|
||||
<Edit className="size-3.5" /> Düzenle
|
||||
</Link>
|
||||
<form action={deleteProject}>
|
||||
<input type="hidden" name="id" value={p.$id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRow } from "@/lib/data";
|
||||
import { TABLES } from "@/lib/appwrite-server";
|
||||
import type { TestimonialRow } from "@/lib/types";
|
||||
import { TestimonialForm } from "../../form";
|
||||
|
||||
export default async function EditTestimonialPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const row = await getRow<TestimonialRow>(TABLES.testimonials, id);
|
||||
if (!row) notFound();
|
||||
return <TestimonialForm row={row} />;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
Checkbox,
|
||||
Field,
|
||||
FormActions,
|
||||
FormShell,
|
||||
GhostLink,
|
||||
PageHeader,
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveTestimonial } from "@/lib/admin-actions";
|
||||
import type { TestimonialRow } from "@/lib/types";
|
||||
|
||||
export function TestimonialForm({ row }: { row?: TestimonialRow }) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={row ? "Referansı düzenle" : "Yeni referans"}
|
||||
backHref="/admin/referanslar"
|
||||
/>
|
||||
<form action={saveTestimonial}>
|
||||
{row && <input type="hidden" name="id" value={row.$id} />}
|
||||
<FormShell>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Field label="İsim" name="name" required defaultValue={row?.name} />
|
||||
<Field label="Görsel URL" name="image_url" type="url" defaultValue={row?.image_url} />
|
||||
<Field label="Pozisyon" name="role" defaultValue={row?.role} />
|
||||
<Field label="Firma" name="company" defaultValue={row?.company} />
|
||||
<Field
|
||||
label="Puan (1–5)"
|
||||
name="rating"
|
||||
type="number"
|
||||
defaultValue={row?.rating ?? 5}
|
||||
/>
|
||||
<Field label="Sıra" name="order" type="number" defaultValue={row?.order ?? 0} />
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Yorum"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
defaultValue={row?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Checkbox
|
||||
label="Öne çıkar"
|
||||
name="featured"
|
||||
defaultChecked={row?.featured ?? false}
|
||||
/>
|
||||
</div>
|
||||
<FormActions>
|
||||
<GhostLink href="/admin/referanslar">İptal</GhostLink>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TestimonialForm } from "../form";
|
||||
|
||||
export default function NewTestimonialPage() {
|
||||
return <TestimonialForm />;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { Plus, Edit, Star } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { listTestimonials } from "@/lib/data";
|
||||
import { deleteTestimonial } from "@/lib/admin-actions";
|
||||
|
||||
export default async function TestimonialsAdminPage() {
|
||||
const items = await listTestimonials();
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Referanslar"
|
||||
description="Müşteri yorumları ve değerlendirmeleri."
|
||||
action={
|
||||
<Link
|
||||
href="/admin/referanslar/new"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
<Plus className="size-4" /> Yeni referans
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{items.length === 0 && (
|
||||
<div className="md:col-span-2 rounded-2xl border border-dashed border-[var(--border)] bg-white p-12 text-center text-sm text-[var(--muted)]">
|
||||
Henüz referans eklenmemiş.
|
||||
</div>
|
||||
)}
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.$id}
|
||||
className="rounded-2xl border border-[var(--border)] bg-white p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-[var(--navy)]">{t.name}</p>
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
{[t.role, t.company].filter(Boolean).join(" — ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 text-amber-500">
|
||||
{Array.from({ length: t.rating ?? 5 }).map((_, i) => (
|
||||
<Star key={i} className="size-3.5 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[var(--foreground)] line-clamp-4">
|
||||
“{t.message}”
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--border)] pt-3">
|
||||
<span className="text-xs text-[var(--muted)]">
|
||||
Sıra: {t.order ?? 0} {t.featured && "• Öne çıkan"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/referanslar/${t.$id}/edit`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
|
||||
>
|
||||
<Edit className="size-3.5" /> Düzenle
|
||||
</Link>
|
||||
<form action={deleteTestimonial}>
|
||||
<input type="hidden" name="id" value={t.$id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRow } from "@/lib/data";
|
||||
import { TABLES } from "@/lib/appwrite-server";
|
||||
import type { SeoPageRow } from "@/lib/types";
|
||||
import { SeoPageForm } from "../../page-form";
|
||||
|
||||
export default async function EditSeoPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const row = await getRow<SeoPageRow>(TABLES.seoPages, id);
|
||||
if (!row) notFound();
|
||||
return <SeoPageForm row={row} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SeoPageForm } from "../page-form";
|
||||
|
||||
export default function NewSeoPage() {
|
||||
return <SeoPageForm />;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
Checkbox,
|
||||
Field,
|
||||
FormActions,
|
||||
FormShell,
|
||||
GhostLink,
|
||||
PageHeader,
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { saveSeoPage } from "@/lib/admin-actions";
|
||||
import type { SeoPageRow } from "@/lib/types";
|
||||
|
||||
export function SeoPageForm({ row }: { row?: SeoPageRow }) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={row ? "Sayfa SEO override" : "Yeni sayfa SEO override"}
|
||||
backHref="/admin/seo"
|
||||
description="Sadece bu path için title/description/og bilgisini değiştir."
|
||||
/>
|
||||
<form action={saveSeoPage}>
|
||||
{row && <input type="hidden" name="id" value={row.$id} />}
|
||||
<FormShell>
|
||||
<div className="grid gap-5">
|
||||
<Field
|
||||
label="Sayfa yolu (path)"
|
||||
name="path"
|
||||
required
|
||||
defaultValue={row?.path}
|
||||
placeholder="/hizmetler"
|
||||
help="Örn: /, /hizmetler, /blog/yeni-yazi"
|
||||
/>
|
||||
<Field label="Başlık" name="title" defaultValue={row?.title} />
|
||||
<Textarea
|
||||
label="Açıklama"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={row?.description}
|
||||
/>
|
||||
<Field
|
||||
label="OG görseli"
|
||||
name="og_image"
|
||||
type="url"
|
||||
defaultValue={row?.og_image}
|
||||
/>
|
||||
<Field
|
||||
label="Canonical URL"
|
||||
name="canonical"
|
||||
type="url"
|
||||
defaultValue={row?.canonical}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Aramada gizle (noindex)"
|
||||
name="noindex"
|
||||
defaultChecked={row?.noindex ?? false}
|
||||
/>
|
||||
</div>
|
||||
<FormActions>
|
||||
<GhostLink href="/admin/seo">İptal</GhostLink>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Link from "next/link";
|
||||
import { Edit, Plus, Save } from "lucide-react";
|
||||
import {
|
||||
Field,
|
||||
FormActions,
|
||||
FormShell,
|
||||
PageHeader,
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { getSeoSettings, listSeoPages } from "@/lib/data";
|
||||
import { deleteSeoPage, saveSeoSettings } from "@/lib/admin-actions";
|
||||
|
||||
export default async function SeoAdminPage() {
|
||||
const [settings, pages] = await Promise.all([
|
||||
getSeoSettings(),
|
||||
listSeoPages(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<section>
|
||||
<PageHeader
|
||||
title="SEO yönetimi"
|
||||
description="Global site ayarları ve sayfa bazlı meta override."
|
||||
/>
|
||||
|
||||
<form action={saveSeoSettings}>
|
||||
<FormShell>
|
||||
<h2 className="text-base font-semibold text-[var(--navy)]">
|
||||
Global ayarlar
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-[var(--muted)]">
|
||||
Tüm sayfalarda varsayılan olarak kullanılır.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<Field
|
||||
label="Site adı"
|
||||
name="site_name"
|
||||
defaultValue={settings?.site_name}
|
||||
placeholder="Kovak Yazılım"
|
||||
/>
|
||||
<Field
|
||||
label="Varsayılan OG görseli"
|
||||
name="default_og_image"
|
||||
type="url"
|
||||
defaultValue={settings?.default_og_image}
|
||||
/>
|
||||
<Field
|
||||
label="Twitter handle"
|
||||
name="twitter_handle"
|
||||
defaultValue={settings?.twitter_handle}
|
||||
placeholder="@kovakyazilim"
|
||||
/>
|
||||
<Field
|
||||
label="LinkedIn URL"
|
||||
name="linkedin_url"
|
||||
type="url"
|
||||
defaultValue={settings?.linkedin_url}
|
||||
/>
|
||||
<Field
|
||||
label="Facebook URL"
|
||||
name="facebook_url"
|
||||
type="url"
|
||||
defaultValue={settings?.facebook_url}
|
||||
/>
|
||||
<Field
|
||||
label="Instagram URL"
|
||||
name="instagram_url"
|
||||
type="url"
|
||||
defaultValue={settings?.instagram_url}
|
||||
/>
|
||||
<Field
|
||||
label="Google Site Verification"
|
||||
name="google_site_verification"
|
||||
defaultValue={settings?.google_site_verification}
|
||||
/>
|
||||
<Field
|
||||
label="Google Tag Manager ID"
|
||||
name="gtm_id"
|
||||
defaultValue={settings?.gtm_id}
|
||||
placeholder="GTM-XXXXXXX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Site açıklaması (varsayılan)"
|
||||
name="site_description"
|
||||
rows={2}
|
||||
defaultValue={settings?.site_description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormActions>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Global ayarları kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--navy)]">Sayfa override'ları</h2>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Belirli bir sayfa yolu için title/description/og override edin.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/seo/new"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
<Plus className="size-4" /> Sayfa override ekle
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Path</th>
|
||||
<th className="px-4 py-3 text-left">Başlık</th>
|
||||
<th className="px-4 py-3 text-left">Noindex</th>
|
||||
<th className="px-4 py-3 text-right">İşlem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-12 text-center text-[var(--muted)]">
|
||||
Override eklenmemiş.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{pages.map((p) => (
|
||||
<tr key={p.$id} className="border-t border-[var(--border)]">
|
||||
<td className="px-4 py-3 font-mono text-xs text-[var(--navy)]">{p.path}</td>
|
||||
<td className="px-4 py-3 text-[var(--muted)]">{p.title ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.noindex ? (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-800">
|
||||
noindex
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/seo/${p.$id}/edit`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
|
||||
>
|
||||
<Edit className="size-3.5" /> Düzenle
|
||||
</Link>
|
||||
<form action={deleteSeoPage}>
|
||||
<input type="hidden" name="id" value={p.$id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Account, Client } from "node-appwrite";
|
||||
import { SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
|
||||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
|
||||
|
||||
export type LoginState = { error?: string };
|
||||
|
||||
export async function loginAction(
|
||||
_prev: LoginState | undefined,
|
||||
formData: FormData,
|
||||
): Promise<LoginState> {
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
if (!email || !password) return { error: "E-posta ve şifre zorunlu" };
|
||||
|
||||
try {
|
||||
const client = new Client().setEndpoint(endpoint).setProject(projectId);
|
||||
const account = new Account(client);
|
||||
const session = await account.createEmailPasswordSession({ email, password });
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE, session.secret, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
expires: new Date(session.expire),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Giriş başarısız";
|
||||
return { error: msg };
|
||||
}
|
||||
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
const store = await cookies();
|
||||
store.delete(SESSION_COOKIE);
|
||||
redirect("/admin/login");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { loginAction, type LoginState } from "./actions";
|
||||
import { AlertCircle, Loader2, LogIn } from "lucide-react";
|
||||
|
||||
const initial: LoginState = {};
|
||||
|
||||
export function LoginForm() {
|
||||
const [state, action, pending] = useActionState(loginAction, initial);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--navy)]">E-posta</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none transition focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
|
||||
placeholder="admin@kovakyazilim.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--navy)]">Şifre</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none transition focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-[var(--navy)] px-4 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--navy-700)] disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="size-4" />
|
||||
)}
|
||||
Giriş Yap
|
||||
</button>
|
||||
{state?.error && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{state.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Image from "next/image";
|
||||
import type { Metadata } from "next";
|
||||
import { LoginForm } from "./form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Yönetici Girişi",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[var(--navy-50)] via-white to-[var(--sky-50)] px-6 py-12">
|
||||
<div className="w-full max-w-md rounded-2xl border border-[var(--border)] bg-white p-8 shadow-xl shadow-[var(--navy)]/5">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Image src="/logo.png" alt="Kovak Yazılım" width={56} height={56} />
|
||||
<h1 className="mt-4 text-xl font-semibold text-[var(--navy)]">
|
||||
Yönetici Paneli
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Devam etmek için giriş yapın
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-5
@@ -1,8 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -40,9 +38,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-white text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user