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:
@@ -1,13 +1,14 @@
|
||||
# Kovak Yazılım — Kurumsal Web Sitesi
|
||||
# Kovak Yazılım — Kurumsal Site + Admin Panel
|
||||
|
||||
Next.js 16 + TypeScript + Tailwind v4 + Appwrite ile geliştirilmiş kurumsal site.
|
||||
Next.js 16 + TypeScript + Tailwind v4 + Appwrite ile geliştirilmiş kurumsal site ve içerik yönetim paneli.
|
||||
|
||||
## Teknoloji
|
||||
|
||||
- **Framework:** Next.js 16 (App Router, Turbopack, React 19)
|
||||
- **Stil:** Tailwind CSS v4
|
||||
- **Backend:** Appwrite (TablesDB) — `https://db.kovaksoft.com`
|
||||
- **İkonlar:** lucide-react + inline SVG brand icons
|
||||
- **Backend:** Appwrite (TablesDB + Storage + Auth) — `https://db.kovaksoft.com`
|
||||
- **İçerik:** Markdown (marked.js)
|
||||
- **İkonlar:** lucide-react + inline SVG
|
||||
- **Form:** React Server Actions + `useActionState`
|
||||
|
||||
## Kurulum
|
||||
@@ -15,59 +16,120 @@ Next.js 16 + TypeScript + Tailwind v4 + Appwrite ile geliştirilmiş kurumsal si
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# .env.local dosyasındaki APPWRITE_API_KEY'i Appwrite Console'dan oluşturup ekle
|
||||
# .env.local içine APPWRITE_API_KEY'i Appwrite Console'dan oluşturup ekle
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Site: <http://localhost:3000>
|
||||
Admin: <http://localhost:3000/admin/login>
|
||||
|
||||
## İlk Admin Kullanıcısı
|
||||
|
||||
1. Appwrite Console → Auth → Users → Create User
|
||||
2. Email + şifre belirle
|
||||
3. `/admin/login` üzerinden giriş yap
|
||||
|
||||
## Appwrite Yapılandırması
|
||||
|
||||
**Project ID:** `69f27b51000a5bee46ce`
|
||||
**Database ID:** `kovak-yazilim-db`
|
||||
**Bucket ID:** `kovak-yazilim-media`
|
||||
|
||||
### Tablolar
|
||||
|
||||
| Tablo | İçerik | Yazma izni |
|
||||
|---|---|---|
|
||||
| `services` | Hizmetler (slug, title, description, icon, order, featured) | yalnız users |
|
||||
| `projects` | Referans projeler (slug, title, description, image_url, live_url, category, technologies[], year, featured) | yalnız users |
|
||||
| `contact_messages` | İletişim formu kayıtları (name, email, phone, subject, message, status) | herkes create, users read/update/delete |
|
||||
| Tablo | İçerik |
|
||||
|---|---|
|
||||
| `services` | Hizmet kartları (slug, title, description, icon, order, featured) |
|
||||
| `projects` | Referans projeler (slug, title, description, image_url, live_url, category, technologies[], year, featured) |
|
||||
| `blog_posts` | Blog yazıları (slug, title, excerpt, content, cover_image, author, status, published_at, tags[], seo_*) |
|
||||
| `testimonials` | Müşteri yorumları (name, role, company, message, rating, image_url, order, featured) |
|
||||
| `seo_pages` | Sayfa bazlı SEO override (path, title, description, og_image, canonical, noindex) |
|
||||
| `seo_settings` | Global SEO ayarları (singleton — rowId: `global`) |
|
||||
| `contact_messages` | İletişim formu kayıtları (anonim create, users read/update/delete) |
|
||||
|
||||
`contact_messages.create` izni `any` — anonim kullanıcılar form gönderebilsin diye.
|
||||
Diğer tüm yazma işlemleri yetkili kullanıcı (admin) gerektirir.
|
||||
### Storage
|
||||
|
||||
### API Key Oluşturma
|
||||
`kovak-yazilim-media` — 10 MB max, image-only (jpg/png/webp/gif/svg/avif). Public read.
|
||||
|
||||
1. Appwrite Console → Settings → API Keys → Create
|
||||
2. Scopes: `databases.read`, `tables.read`, `rows.read`, `rows.write`
|
||||
3. `.env.local` içine `APPWRITE_API_KEY=` olarak yapıştır
|
||||
### API Key
|
||||
|
||||
Appwrite Console → Settings → API Keys → Create
|
||||
Scopes: `databases.read`, `tables.read`, `rows.read`, `rows.write`, `files.read`, `files.write`, `users.read`
|
||||
|
||||
## Admin Paneli
|
||||
|
||||
`/admin` altında:
|
||||
|
||||
- **Pano** (`/admin`) — Sayım kartları + hızlı aksiyonlar
|
||||
- **Blog** (`/admin/blog`) — Yazı CRUD, draft/published durumu, markdown editor
|
||||
- **Hizmetler** (`/admin/hizmetler`) — Hizmet CRUD, lucide ikon seçici
|
||||
- **Projeler** (`/admin/projeler`) — Portfolyo CRUD
|
||||
- **Referanslar** (`/admin/referanslar`) — Müşteri yorumları
|
||||
- **SEO** (`/admin/seo`) — Global meta + sayfa bazlı override
|
||||
- **Mesajlar** (`/admin/iletisim`) — Form inbox, status (new/read/replied/archived)
|
||||
- **Medya** (`/admin/medya`) — Appwrite Storage browser, upload/delete
|
||||
|
||||
### Auth Akışı
|
||||
|
||||
`lib/auth.ts` → `getCurrentUser()` & `requireUser()`
|
||||
Login → `account.createEmailPasswordSession` → session secret HTTP-only cookie (`kovak_session`)
|
||||
Admin layout (`app/admin/(protected)/layout.tsx`) `requireUser()` çağrısı yapar — yetkisiz giriş `/admin/login`'e redirect.
|
||||
|
||||
## SEO Sistemi
|
||||
|
||||
`lib/seo.ts` → `buildMetadata(path, fallback)`
|
||||
|
||||
Sıralama (override öncelikli):
|
||||
1. `seo_pages` tablosunda o path için kayıt varsa → onun title/description/og_image kullanılır
|
||||
2. Yoksa sayfanın kendi fallback `Metadata` objesi
|
||||
3. O da yoksa `seo_settings` (global)
|
||||
4. O da yoksa `lib/site-config.ts`
|
||||
|
||||
## Yapı
|
||||
|
||||
```
|
||||
app/
|
||||
actions.ts # Server Action: submitContact
|
||||
layout.tsx # Root layout + Header/Footer
|
||||
(site)/ # Public site (Header + Footer ortak)
|
||||
page.tsx # Anasayfa
|
||||
hizmetler/ # /hizmetler
|
||||
projeler/ # /projeler
|
||||
hakkimizda/ # /hakkimizda
|
||||
iletisim/ # /iletisim
|
||||
components/ # Header, Footer, Hero, ContactForm, …
|
||||
blog/ # /blog, /blog/[slug]
|
||||
hakkimizda/
|
||||
iletisim/
|
||||
admin/
|
||||
login/ # /admin/login (auth dışı)
|
||||
(protected)/ # requireUser() ile korunan grup
|
||||
page.tsx # /admin
|
||||
blog/
|
||||
hizmetler/
|
||||
projeler/
|
||||
referanslar/
|
||||
seo/
|
||||
iletisim/
|
||||
medya/
|
||||
actions.ts # Public Server Action: submitContact
|
||||
layout.tsx # Root layout (html/body)
|
||||
components/
|
||||
admin/ # Sidebar, topbar, form helpers, delete button
|
||||
header.tsx, footer.tsx
|
||||
hero.tsx, services-grid.tsx, projects-grid.tsx, testimonials.tsx
|
||||
contact-form.tsx
|
||||
lib/
|
||||
appwrite.ts # Browser client
|
||||
appwrite-server.ts # Server client (uses APPWRITE_API_KEY)
|
||||
data.ts # listServices / listProjects (Server-only)
|
||||
site-config.ts # Marka, iletişim, fallback hizmetler
|
||||
types.ts
|
||||
public/logo.png # Logo (kovakyazilim.com'dan)
|
||||
appwrite-server.ts # adminClient (API key) + sessionClient (cookie)
|
||||
auth.ts # Session helpers
|
||||
admin-actions.ts # Tüm CRUD server actions (gate() ile auth check)
|
||||
data.ts # listX / getX sorguları
|
||||
seo.ts # buildMetadata
|
||||
site-config.ts # Marka + fallback değerler
|
||||
types.ts # Row tipleri
|
||||
public/logo.png # Logo
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run build # 23 route, public sayfalar static, admin dynamic
|
||||
npm start
|
||||
```
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export function DeleteButton({
|
||||
label = "Sil",
|
||||
confirm = "Bu kaydı silmek istediğinize emin misiniz?",
|
||||
}: {
|
||||
label?: string;
|
||||
confirm?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!window.confirm(confirm)) e.preventDefault();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-white px-2.5 py-1.5 text-xs font-medium text-red-700 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
backHref,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
backHref?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
{backHref && (
|
||||
<Link
|
||||
href={backHref}
|
||||
className="mb-2 inline-flex items-center gap-1 text-xs text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Geri
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold text-[var(--navy)]">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
placeholder,
|
||||
required,
|
||||
help,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
defaultValue?: string | number | null;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-[var(--navy)]">
|
||||
{label}
|
||||
{required && <span className="text-red-500"> *</span>}
|
||||
</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
{help && <span className="mt-1 block text-xs text-[var(--muted)]">{help}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
label,
|
||||
name,
|
||||
defaultValue,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
required,
|
||||
help,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue?: string | null;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-[var(--navy)]">
|
||||
{label}
|
||||
{required && <span className="text-red-500"> *</span>}
|
||||
</span>
|
||||
<textarea
|
||||
name={name}
|
||||
required={required}
|
||||
rows={rows}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
{help && <span className="mt-1 block text-xs text-[var(--muted)]">{help}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
name,
|
||||
options,
|
||||
defaultValue,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
options: { value: string; label: string }[];
|
||||
defaultValue?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-[var(--navy)]">{label}</span>
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
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"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
name,
|
||||
defaultChecked,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultChecked?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
defaultChecked={defaultChecked}
|
||||
className="size-4 rounded border-[var(--border)] text-[var(--navy)] focus:ring-[var(--sky)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--foreground)]">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-6 rounded-2xl border border-[var(--border)] bg-white p-6 sm:p-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormActions({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-3 border-t border-[var(--border)] pt-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryButton({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="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)]"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function GhostLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm font-medium text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Newspaper,
|
||||
Layers,
|
||||
Briefcase,
|
||||
MessageSquareQuote,
|
||||
Search,
|
||||
Inbox,
|
||||
Image as ImageIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type Item = { href: string; label: string; icon: LucideIcon };
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: "/admin", label: "Pano", icon: LayoutDashboard },
|
||||
{ href: "/admin/blog", label: "Blog", icon: Newspaper },
|
||||
{ href: "/admin/hizmetler", label: "Hizmetler", icon: Layers },
|
||||
{ href: "/admin/projeler", label: "Projeler", icon: Briefcase },
|
||||
{ href: "/admin/referanslar", label: "Referanslar", icon: MessageSquareQuote },
|
||||
{ href: "/admin/seo", label: "SEO", icon: Search },
|
||||
{ href: "/admin/iletisim", label: "Mesajlar", icon: Inbox },
|
||||
{ href: "/admin/medya", label: "Medya", icon: ImageIcon },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 shrink-0 border-r border-[var(--border)] bg-white md:flex md:flex-col">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-3 border-b border-[var(--border)] px-5 py-4"
|
||||
>
|
||||
<Image src="/logo.png" alt="Kovak Yazılım" width={36} height={36} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--navy)]">Kovak Yazılım</p>
|
||||
<p className="text-xs text-[var(--muted)]">Yönetim Paneli</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{items.map((it) => {
|
||||
const active =
|
||||
it.href === "/admin"
|
||||
? pathname === "/admin"
|
||||
: pathname.startsWith(it.href);
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition ${
|
||||
active
|
||||
? "bg-[var(--navy)] text-white"
|
||||
: "text-[var(--muted)] hover:bg-[var(--navy-50)] hover:text-[var(--navy)]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{it.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="border-t border-[var(--border)] px-5 py-3 text-xs text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
>
|
||||
↗ Siteyi görüntüle
|
||||
</Link>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { logoutAction } from "@/app/admin/login/actions";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
export function AdminTopbar({ email, name }: { email: string; name?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] bg-white px-6 py-3 md:px-10">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--muted)]">Giriş yapan</p>
|
||||
<p className="text-sm font-medium text-[var(--navy)]">
|
||||
{name || email}
|
||||
</p>
|
||||
</div>
|
||||
<form action={logoutAction}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--muted)] transition hover:border-red-300 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="size-3.5" />
|
||||
Çıkış
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const nav = [
|
||||
{ href: "/", label: "Anasayfa" },
|
||||
{ href: "/hizmetler", label: "Hizmetler" },
|
||||
{ href: "/projeler", label: "Projeler" },
|
||||
{ href: "/blog", label: "Blog" },
|
||||
{ href: "/hakkimizda", label: "Hakkımızda" },
|
||||
{ href: "/iletisim", label: "İletişim" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Quote, Star } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { TestimonialRow } from "@/lib/types";
|
||||
|
||||
export function TestimonialsCarousel({ items }: { items: TestimonialRow[] }) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((t) => (
|
||||
<figure
|
||||
key={t.$id}
|
||||
className="relative rounded-2xl border border-[var(--border)] bg-white p-6"
|
||||
>
|
||||
<Quote
|
||||
className="absolute right-5 top-5 size-8 text-[var(--sky-50)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<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>
|
||||
<blockquote className="mt-4 text-sm leading-relaxed text-[var(--foreground)]">
|
||||
“{t.message}”
|
||||
</blockquote>
|
||||
<figcaption className="mt-5 flex items-center gap-3 border-t border-[var(--border)] pt-4">
|
||||
{t.image_url ? (
|
||||
<Image
|
||||
src={t.image_url}
|
||||
alt={t.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-[var(--navy-50)] text-sm font-semibold text-[var(--navy)]">
|
||||
{t.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--navy)]">{t.name}</p>
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
{[t.role, t.company].filter(Boolean).join(" — ")}
|
||||
</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ID } from "node-appwrite";
|
||||
import { InputFile } from "node-appwrite/file";
|
||||
import {
|
||||
adminDB,
|
||||
adminStorage,
|
||||
DATABASE_ID,
|
||||
MEDIA_BUCKET_ID,
|
||||
TABLES,
|
||||
} from "@/lib/appwrite-server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
async function gate() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) throw new Error("Yetkisiz");
|
||||
}
|
||||
|
||||
function slugify(s: string) {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[ğ]/g, "g")
|
||||
.replace(/[ü]/g, "u")
|
||||
.replace(/[ş]/g, "s")
|
||||
.replace(/[ı]/g, "i")
|
||||
.replace(/[ö]/g, "o")
|
||||
.replace(/[ç]/g, "c")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function num(v: FormDataEntryValue | null) {
|
||||
if (v === null || v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function bool(v: FormDataEntryValue | null) {
|
||||
return v === "on" || v === "true" || v === "1";
|
||||
}
|
||||
function str(v: FormDataEntryValue | null) {
|
||||
if (v === null) return null;
|
||||
const s = String(v).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
function strArr(v: FormDataEntryValue | null) {
|
||||
if (v === null) return null;
|
||||
return String(v)
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Media upload ────────────────────────────────────────────────
|
||||
|
||||
export async function uploadMedia(formData: FormData): Promise<void> {
|
||||
await gate();
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
throw new Error("Dosya seçilmedi");
|
||||
}
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
await adminStorage.createFile({
|
||||
bucketId: MEDIA_BUCKET_ID,
|
||||
fileId: ID.unique(),
|
||||
file: InputFile.fromBuffer(bytes, file.name),
|
||||
});
|
||||
revalidatePath("/admin/medya");
|
||||
}
|
||||
|
||||
export async function deleteMediaFile(fileId: string) {
|
||||
await gate();
|
||||
await adminStorage.deleteFile({ bucketId: MEDIA_BUCKET_ID, fileId });
|
||||
revalidatePath("/admin/medya");
|
||||
}
|
||||
|
||||
// ─── Blog ────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveBlogPost(formData: FormData) {
|
||||
await gate();
|
||||
const id = str(formData.get("id"));
|
||||
const title = str(formData.get("title"));
|
||||
if (!title) throw new Error("Başlık zorunlu");
|
||||
const slug = str(formData.get("slug")) || slugify(title);
|
||||
const status = (str(formData.get("status")) ?? "draft") as "draft" | "published";
|
||||
|
||||
const data = {
|
||||
slug,
|
||||
title,
|
||||
excerpt: str(formData.get("excerpt")),
|
||||
content: str(formData.get("content")),
|
||||
cover_image: str(formData.get("cover_image")),
|
||||
cover_file_id: str(formData.get("cover_file_id")),
|
||||
author: str(formData.get("author")),
|
||||
status,
|
||||
published_at:
|
||||
status === "published"
|
||||
? str(formData.get("published_at")) || new Date().toISOString()
|
||||
: null,
|
||||
tags: strArr(formData.get("tags")),
|
||||
seo_title: str(formData.get("seo_title")),
|
||||
seo_description: str(formData.get("seo_description")),
|
||||
seo_image: str(formData.get("seo_image")),
|
||||
};
|
||||
|
||||
if (id) {
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.blogPosts,
|
||||
rowId: id,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.blogPosts,
|
||||
rowId: ID.unique(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/blog");
|
||||
revalidatePath("/blog");
|
||||
if (slug) revalidatePath(`/blog/${slug}`);
|
||||
}
|
||||
|
||||
export async function deleteBlogPost(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
await adminDB.deleteRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.blogPosts,
|
||||
rowId: id,
|
||||
});
|
||||
revalidatePath("/admin/blog");
|
||||
revalidatePath("/blog");
|
||||
}
|
||||
|
||||
// ─── Services ────────────────────────────────────────────────────
|
||||
|
||||
export async function saveService(formData: FormData) {
|
||||
await gate();
|
||||
const id = str(formData.get("id"));
|
||||
const title = str(formData.get("title"));
|
||||
if (!title) throw new Error("Başlık zorunlu");
|
||||
const description = str(formData.get("description"));
|
||||
if (!description) throw new Error("Açıklama zorunlu");
|
||||
const slug = str(formData.get("slug")) || slugify(title);
|
||||
|
||||
const data = {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
icon: str(formData.get("icon")),
|
||||
order: num(formData.get("order")) ?? 0,
|
||||
featured: bool(formData.get("featured")),
|
||||
};
|
||||
if (id) {
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
rowId: id,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
rowId: slug,
|
||||
data,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/hizmetler");
|
||||
revalidatePath("/hizmetler");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteService(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
await adminDB.deleteRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
rowId: id,
|
||||
});
|
||||
revalidatePath("/admin/hizmetler");
|
||||
revalidatePath("/hizmetler");
|
||||
}
|
||||
|
||||
// ─── Projects ────────────────────────────────────────────────────
|
||||
|
||||
export async function saveProject(formData: FormData) {
|
||||
await gate();
|
||||
const id = str(formData.get("id"));
|
||||
const title = str(formData.get("title"));
|
||||
if (!title) throw new Error("Başlık zorunlu");
|
||||
const slug = str(formData.get("slug")) || slugify(title);
|
||||
const description = str(formData.get("description"));
|
||||
if (!description) throw new Error("Açıklama zorunlu");
|
||||
|
||||
const data = {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
image_url: str(formData.get("image_url")),
|
||||
live_url: str(formData.get("live_url")),
|
||||
category: str(formData.get("category")),
|
||||
technologies: strArr(formData.get("technologies")),
|
||||
year: num(formData.get("year")),
|
||||
featured: bool(formData.get("featured")),
|
||||
};
|
||||
|
||||
if (id) {
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.projects,
|
||||
rowId: id,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.projects,
|
||||
rowId: ID.unique(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/projeler");
|
||||
revalidatePath("/projeler");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteProject(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
await adminDB.deleteRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.projects,
|
||||
rowId: id,
|
||||
});
|
||||
revalidatePath("/admin/projeler");
|
||||
revalidatePath("/projeler");
|
||||
}
|
||||
|
||||
// ─── Testimonials ────────────────────────────────────────────────
|
||||
|
||||
export async function saveTestimonial(formData: FormData) {
|
||||
await gate();
|
||||
const id = str(formData.get("id"));
|
||||
const name = str(formData.get("name"));
|
||||
if (!name) throw new Error("Ad zorunlu");
|
||||
const message = str(formData.get("message"));
|
||||
if (!message) throw new Error("Mesaj zorunlu");
|
||||
|
||||
const data = {
|
||||
name,
|
||||
role: str(formData.get("role")),
|
||||
company: str(formData.get("company")),
|
||||
message,
|
||||
rating: num(formData.get("rating")) ?? 5,
|
||||
image_url: str(formData.get("image_url")),
|
||||
order: num(formData.get("order")) ?? 0,
|
||||
featured: bool(formData.get("featured")),
|
||||
};
|
||||
|
||||
if (id) {
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.testimonials,
|
||||
rowId: id,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.testimonials,
|
||||
rowId: ID.unique(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/referanslar");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteTestimonial(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
await adminDB.deleteRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.testimonials,
|
||||
rowId: id,
|
||||
});
|
||||
revalidatePath("/admin/referanslar");
|
||||
}
|
||||
|
||||
// ─── SEO Settings (singleton) ────────────────────────────────────
|
||||
|
||||
export async function saveSeoSettings(formData: FormData) {
|
||||
await gate();
|
||||
const data = {
|
||||
site_name: str(formData.get("site_name")),
|
||||
site_description: str(formData.get("site_description")),
|
||||
default_og_image: str(formData.get("default_og_image")),
|
||||
twitter_handle: str(formData.get("twitter_handle")),
|
||||
facebook_url: str(formData.get("facebook_url")),
|
||||
linkedin_url: str(formData.get("linkedin_url")),
|
||||
instagram_url: str(formData.get("instagram_url")),
|
||||
google_site_verification: str(formData.get("google_site_verification")),
|
||||
gtm_id: str(formData.get("gtm_id")),
|
||||
};
|
||||
try {
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.seoSettings,
|
||||
rowId: "global",
|
||||
data,
|
||||
});
|
||||
} catch {
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.seoSettings,
|
||||
rowId: "global",
|
||||
data,
|
||||
});
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/seo");
|
||||
}
|
||||
|
||||
// ─── SEO Page (per path) ─────────────────────────────────────────
|
||||
|
||||
export async function saveSeoPage(formData: FormData) {
|
||||
await gate();
|
||||
const id = str(formData.get("id"));
|
||||
const path = str(formData.get("path"));
|
||||
if (!path) throw new Error("Path zorunlu");
|
||||
|
||||
const data = {
|
||||
path,
|
||||
title: str(formData.get("title")),
|
||||
description: str(formData.get("description")),
|
||||
og_image: str(formData.get("og_image")),
|
||||
canonical: str(formData.get("canonical")),
|
||||
noindex: bool(formData.get("noindex")),
|
||||
};
|
||||
|
||||
if (id) {
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.seoPages,
|
||||
rowId: id,
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
await adminDB.createRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.seoPages,
|
||||
rowId: ID.unique(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
revalidatePath(path);
|
||||
revalidatePath("/admin/seo");
|
||||
}
|
||||
|
||||
export async function deleteSeoPage(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
await adminDB.deleteRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.seoPages,
|
||||
rowId: id,
|
||||
});
|
||||
revalidatePath("/admin/seo");
|
||||
}
|
||||
|
||||
// ─── Contact messages ────────────────────────────────────────────
|
||||
|
||||
export async function updateMessageStatus(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
const status = String(formData.get("status")) as
|
||||
| "new"
|
||||
| "read"
|
||||
| "replied"
|
||||
| "archived";
|
||||
await adminDB.updateRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.contactMessages,
|
||||
rowId: id,
|
||||
data: { status },
|
||||
});
|
||||
revalidatePath("/admin/iletisim");
|
||||
}
|
||||
|
||||
export async function deleteMessage(formData: FormData) {
|
||||
await gate();
|
||||
const id = String(formData.get("id"));
|
||||
await adminDB.deleteRow({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.contactMessages,
|
||||
rowId: id,
|
||||
});
|
||||
revalidatePath("/admin/iletisim");
|
||||
}
|
||||
+20
-4
@@ -1,23 +1,39 @@
|
||||
import "server-only";
|
||||
import { Client, TablesDB } from "node-appwrite";
|
||||
import { Account, Client, Storage, TablesDB } from "node-appwrite";
|
||||
|
||||
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
|
||||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
|
||||
const apiKey = process.env.APPWRITE_API_KEY;
|
||||
|
||||
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!;
|
||||
export const MEDIA_BUCKET_ID =
|
||||
process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID ?? "kovak-yazilim-media";
|
||||
|
||||
export const TABLES = {
|
||||
contactMessages: "contact_messages",
|
||||
services: "services",
|
||||
projects: "projects",
|
||||
blogPosts: "blog_posts",
|
||||
testimonials: "testimonials",
|
||||
seoPages: "seo_pages",
|
||||
seoSettings: "seo_settings",
|
||||
} as const;
|
||||
|
||||
function buildClient() {
|
||||
export function adminClient() {
|
||||
const c = new Client().setEndpoint(endpoint).setProject(projectId);
|
||||
if (apiKey) c.setKey(apiKey);
|
||||
return c;
|
||||
}
|
||||
|
||||
export const serverClient = buildClient();
|
||||
export const serverTablesDB = new TablesDB(serverClient);
|
||||
export function sessionClient(sessionSecret: string) {
|
||||
return new Client()
|
||||
.setEndpoint(endpoint)
|
||||
.setProject(projectId)
|
||||
.setSession(sessionSecret);
|
||||
}
|
||||
|
||||
export const adminDB = new TablesDB(adminClient());
|
||||
export const adminStorage = new Storage(adminClient());
|
||||
export const adminAccount = new Account(adminClient());
|
||||
|
||||
export { Account, TablesDB, Storage };
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Account } from "node-appwrite";
|
||||
import { sessionClient } from "@/lib/appwrite-server";
|
||||
|
||||
export const SESSION_COOKIE = "kovak_session";
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const store = await cookies();
|
||||
const secret = store.get(SESSION_COOKIE)?.value;
|
||||
if (!secret) return null;
|
||||
try {
|
||||
const account = new Account(sessionClient(secret));
|
||||
return await account.get();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/admin/login");
|
||||
return user;
|
||||
}
|
||||
+91
-15
@@ -1,34 +1,110 @@
|
||||
import "server-only";
|
||||
import { Query } from "node-appwrite";
|
||||
import { serverTablesDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
|
||||
import type { ProjectRow, ServiceRow } from "@/lib/types";
|
||||
import { adminDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
|
||||
import type {
|
||||
BlogPostRow,
|
||||
ContactMessageRow,
|
||||
ProjectRow,
|
||||
ServiceRow,
|
||||
SeoPageRow,
|
||||
SeoSettingsRow,
|
||||
TestimonialRow,
|
||||
} from "@/lib/types";
|
||||
|
||||
export async function listServices(opts?: { featured?: boolean }) {
|
||||
const queries = [Query.orderAsc("order"), Query.limit(50)];
|
||||
if (opts?.featured) queries.unshift(Query.equal("featured", true));
|
||||
async function safeList<T>(
|
||||
tableId: string,
|
||||
queries: string[],
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const res = await serverTablesDB.listRows<ServiceRow>({
|
||||
const res = await adminDB.listRows<T extends import("appwrite").Models.Row ? T : never>({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
tableId,
|
||||
queries,
|
||||
});
|
||||
return res.rows;
|
||||
return res.rows as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listServices(opts?: { featured?: boolean }) {
|
||||
const q = [Query.orderAsc("order"), Query.limit(50)];
|
||||
if (opts?.featured) q.unshift(Query.equal("featured", true));
|
||||
return safeList<ServiceRow>(TABLES.services, q);
|
||||
}
|
||||
|
||||
export async function listProjects(opts?: { featured?: boolean; limit?: number }) {
|
||||
const queries = [Query.orderDesc("year"), Query.limit(opts?.limit ?? 50)];
|
||||
if (opts?.featured) queries.unshift(Query.equal("featured", true));
|
||||
const q = [Query.orderDesc("year"), Query.limit(opts?.limit ?? 50)];
|
||||
if (opts?.featured) q.unshift(Query.equal("featured", true));
|
||||
return safeList<ProjectRow>(TABLES.projects, q);
|
||||
}
|
||||
|
||||
export async function listPublishedPosts(opts?: { limit?: number }) {
|
||||
return safeList<BlogPostRow>(TABLES.blogPosts, [
|
||||
Query.equal("status", "published"),
|
||||
Query.orderDesc("published_at"),
|
||||
Query.limit(opts?.limit ?? 50),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function listAllPosts() {
|
||||
return safeList<BlogPostRow>(TABLES.blogPosts, [
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string): Promise<BlogPostRow | null> {
|
||||
const res = await safeList<BlogPostRow>(TABLES.blogPosts, [
|
||||
Query.equal("slug", slug),
|
||||
Query.limit(1),
|
||||
]);
|
||||
return res[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listTestimonials(opts?: { featured?: boolean }) {
|
||||
const q = [Query.orderAsc("order"), Query.limit(50)];
|
||||
if (opts?.featured) q.unshift(Query.equal("featured", true));
|
||||
return safeList<TestimonialRow>(TABLES.testimonials, q);
|
||||
}
|
||||
|
||||
export async function listMessages(status?: ContactMessageRow["status"]) {
|
||||
const q = [Query.orderDesc("$createdAt"), Query.limit(200)];
|
||||
if (status) q.unshift(Query.equal("status", status));
|
||||
return safeList<ContactMessageRow>(TABLES.contactMessages, q);
|
||||
}
|
||||
|
||||
export async function getSeoPage(path: string): Promise<SeoPageRow | null> {
|
||||
const res = await safeList<SeoPageRow>(TABLES.seoPages, [
|
||||
Query.equal("path", path),
|
||||
Query.limit(1),
|
||||
]);
|
||||
return res[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listSeoPages() {
|
||||
return safeList<SeoPageRow>(TABLES.seoPages, [
|
||||
Query.orderAsc("path"),
|
||||
Query.limit(200),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getSeoSettings(): Promise<SeoSettingsRow | null> {
|
||||
try {
|
||||
const res = await serverTablesDB.listRows<ProjectRow>({
|
||||
return await adminDB.getRow<SeoSettingsRow>({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.projects,
|
||||
queries,
|
||||
tableId: TABLES.seoSettings,
|
||||
rowId: "global",
|
||||
});
|
||||
return res.rows;
|
||||
} catch {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRow<T>(tableId: string, rowId: string): Promise<T | null> {
|
||||
try {
|
||||
return (await adminDB.getRow({ databaseId: DATABASE_ID, tableId, rowId })) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import "server-only";
|
||||
import type { Metadata } from "next";
|
||||
import { getSeoPage, getSeoSettings } from "@/lib/data";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export async function buildMetadata(path: string, fallback?: Metadata): Promise<Metadata> {
|
||||
const [settings, override] = await Promise.all([
|
||||
getSeoSettings(),
|
||||
getSeoPage(path),
|
||||
]);
|
||||
|
||||
const siteName = settings?.site_name || siteConfig.name;
|
||||
const siteDesc = settings?.site_description || siteConfig.tagline;
|
||||
const ogDefault = settings?.default_og_image || "/logo.png";
|
||||
|
||||
const title =
|
||||
override?.title ?? (fallback?.title as string | undefined) ?? siteName;
|
||||
const description =
|
||||
override?.description ??
|
||||
(fallback?.description as string | undefined) ??
|
||||
siteDesc;
|
||||
const ogImage = override?.og_image || ogDefault;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: ogImage ? [{ url: ogImage }] : undefined,
|
||||
type: "website",
|
||||
locale: "tr_TR",
|
||||
siteName,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: ogImage ? [ogImage] : undefined,
|
||||
site: settings?.twitter_handle ?? undefined,
|
||||
},
|
||||
alternates: override?.canonical
|
||||
? { canonical: override.canonical }
|
||||
: undefined,
|
||||
robots: override?.noindex
|
||||
? { index: false, follow: false }
|
||||
: undefined,
|
||||
verification: settings?.google_site_verification
|
||||
? { google: settings.google_site_verification }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
+52
-3
@@ -21,10 +21,59 @@ export interface ProjectRow extends Models.Row {
|
||||
featured?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ContactMessageInput {
|
||||
export interface BlogPostRow extends Models.Row {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string | null;
|
||||
content?: string | null;
|
||||
cover_image?: string | null;
|
||||
cover_file_id?: string | null;
|
||||
author?: string | null;
|
||||
status?: "draft" | "published" | null;
|
||||
published_at?: string | null;
|
||||
tags?: string[] | null;
|
||||
seo_title?: string | null;
|
||||
seo_description?: string | null;
|
||||
seo_image?: string | null;
|
||||
}
|
||||
|
||||
export interface TestimonialRow extends Models.Row {
|
||||
name: string;
|
||||
role?: string | null;
|
||||
company?: string | null;
|
||||
message: string;
|
||||
rating?: number | null;
|
||||
image_url?: string | null;
|
||||
order?: number | null;
|
||||
featured?: boolean | null;
|
||||
}
|
||||
|
||||
export interface SeoPageRow extends Models.Row {
|
||||
path: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
og_image?: string | null;
|
||||
canonical?: string | null;
|
||||
noindex?: boolean | null;
|
||||
}
|
||||
|
||||
export interface SeoSettingsRow extends Models.Row {
|
||||
site_name?: string | null;
|
||||
site_description?: string | null;
|
||||
default_og_image?: string | null;
|
||||
twitter_handle?: string | null;
|
||||
facebook_url?: string | null;
|
||||
linkedin_url?: string | null;
|
||||
instagram_url?: string | null;
|
||||
google_site_verification?: string | null;
|
||||
gtm_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ContactMessageRow extends Models.Row {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
subject?: string;
|
||||
phone?: string | null;
|
||||
subject?: string | null;
|
||||
message: string;
|
||||
status?: "new" | "read" | "replied" | "archived" | null;
|
||||
}
|
||||
|
||||
Generated
+13
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"appwrite": "^25.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.4",
|
||||
"next": "16.2.6",
|
||||
"node-appwrite": "^25.0.0",
|
||||
"react": "19.2.4",
|
||||
@@ -1487,6 +1488,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
||||
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"appwrite": "^25.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.4",
|
||||
"next": "16.2.6",
|
||||
"node-appwrite": "^25.0.0",
|
||||
"react": "19.2.4",
|
||||
|
||||
Reference in New Issue
Block a user