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:
Ege Can Komur
2026-05-20 02:13:09 +03:00
parent 0f20309e4d
commit f833d429fc
52 changed files with 2999 additions and 81 deletions
+93 -31
View File
@@ -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
page.tsx # Anasayfa
hizmetler/ # /hizmetler
projeler/ # /projeler
hakkimizda/ # /hakkimizda
iletisim/ # /iletisim
components/ # Header, Footer, Hero, ContactForm, …
(site)/ # Public site (Header + Footer ortak)
page.tsx # Anasayfa
hizmetler/ # /hizmetler
projeler/ # /projeler
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.ts # Browser client
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
```
+102
View File
@@ -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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowRight, Calendar } from "lucide-react";
import { SectionTitle } from "@/components/section-title";
import { listPublishedPosts } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/blog", {
title: "Blog",
description: "Yazılım, web tasarım, SEO ve dijital pazarlama üzerine yazılar.",
});
}
export default async function BlogIndex() {
const posts = await listPublishedPosts();
return (
<div className="mx-auto max-w-7xl px-6 py-20">
<SectionTitle
eyebrow="Blog"
title="Yazılım, tasarım ve büyüme üzerine"
description="Sektörden notlar, vaka çalışmaları ve teknik rehberler."
/>
<div className="mt-14">
{posts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--navy-50)]/40 p-12 text-center">
<p className="text-sm text-[var(--muted)]">
Henüz yayınlanmış yazı yok.
</p>
</div>
) : (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((p) => (
<article
key={p.$id}
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg"
>
<Link href={`/blog/${p.slug}`}>
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
{p.cover_image ? (
<Image
src={p.cover_image}
alt={p.title}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
className="object-cover transition group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-3xl font-bold text-[var(--navy)]/30">
{p.title.charAt(0)}
</div>
)}
</div>
<div className="p-6">
<p className="flex items-center gap-1.5 text-xs text-[var(--muted)]">
<Calendar className="size-3.5" />
{p.published_at
? new Date(p.published_at).toLocaleDateString("tr-TR")
: "—"}
</p>
<h3 className="mt-2 text-lg font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
{p.title}
</h3>
{p.excerpt && (
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
{p.excerpt}
</p>
)}
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--sky-600)]">
Devamını oku <ArrowRight className="size-3.5" />
</span>
</div>
</Link>
</article>
))}
</div>
)}
</div>
</div>
);
}
@@ -2,12 +2,15 @@ import type { Metadata } from "next";
import Image from "next/image";
import { SectionTitle } from "@/components/section-title";
import { CheckCircle2 } from "lucide-react";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Hakkımızda",
description:
"Kovak Yazılım, Kocaeli merkezli bir teknoloji ajansıdır. Web, mobil ve CRM çözümleri üretir.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/hakkimizda", {
title: "Hakkımızda",
description:
"Kovak Yazılım, Kocaeli merkezli bir teknoloji ajansıdır. Web, mobil ve CRM çözümleri üretir.",
});
}
const values = [
{
@@ -2,12 +2,15 @@ import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { ServicesGrid } from "@/components/services-grid";
import { listServices } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Hizmetler",
description:
"Web tasarım, e-ticaret, mobil uygulama, yazılım geliştirme, CRM ve dijital pazarlama hizmetleri.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/hizmetler", {
title: "Hizmetler",
description:
"Web tasarım, e-ticaret, mobil uygulama, yazılım geliştirme, CRM ve dijital pazarlama hizmetleri.",
});
}
export default async function ServicesPage() {
const services = await listServices();
@@ -3,12 +3,15 @@ import { Mail, MapPin, Phone, Clock } from "lucide-react";
import { SectionTitle } from "@/components/section-title";
import { ContactForm } from "@/components/contact-form";
import { siteConfig } from "@/lib/site-config";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: letişim",
description:
"Projeniz hakkında konuşmak için bize ulaşın. İzmit Sanayi Sitesi, Kocaeli.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/iletisim", {
title: "İletişim",
description:
"Projeniz hakkında konuşmak için bize ulaşın. İzmit Sanayi Sitesi, Kocaeli.",
});
}
export default function ContactPage() {
return (
+16
View File
@@ -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 />
</>
);
}
+25 -2
View File
@@ -1,15 +1,23 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowRight } from "lucide-react";
import { Hero } from "@/components/hero";
import { SectionTitle } from "@/components/section-title";
import { ServicesGrid } from "@/components/services-grid";
import { ProjectsGrid } from "@/components/projects-grid";
import { listProjects, listServices } from "@/lib/data";
import { TestimonialsCarousel } from "@/components/testimonials";
import { listProjects, listServices, listTestimonials } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/");
}
export default async function Home() {
const [services, projects] = await Promise.all([
const [services, projects, testimonials] = await Promise.all([
listServices({ featured: true }),
listProjects({ featured: true, limit: 6 }),
listTestimonials({ featured: true }),
]);
return (
@@ -51,6 +59,21 @@ export default async function Home() {
</div>
</section>
{testimonials.length > 0 && (
<section className="border-y border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
<div className="mx-auto max-w-7xl px-6">
<SectionTitle
eyebrow="Referanslar"
title="Müşterilerimiz ne diyor?"
description="Birlikte çalıştığımız markalardan geri bildirimler."
/>
<div className="mt-12">
<TestimonialsCarousel items={testimonials} />
</div>
</div>
</section>
)}
<section className="relative overflow-hidden bg-[var(--navy)] py-20 text-white">
<div className="absolute -left-20 top-0 size-96 rounded-full bg-[var(--sky)]/20 blur-3xl" aria-hidden />
<div className="relative mx-auto max-w-4xl px-6 text-center">
@@ -2,11 +2,14 @@ import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { ProjectsGrid } from "@/components/projects-grid";
import { listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Projeler",
description: "Tamamladığımız web, mobil ve CRM projelerinden seçkiler.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/projeler", {
title: "Projeler",
description: "Tamamladığımız web, mobil ve CRM projelerinden seçkiler.",
});
}
export default async function ProjectsPage() {
const projects = await listProjects();
+2 -2
View File
@@ -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} />;
}
+137
View File
@@ -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="150160 karakter ideal."
/>
</div>
<FormActions>
<GhostLink href="/admin/blog">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { BlogForm } from "../form";
export default function NewBlogPostPage() {
return <BlogForm />;
}
+103
View File
@@ -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} />;
}
+90
View File
@@ -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 />;
}
+80
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+119
View File
@@ -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>
<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>
);
}
+124
View File
@@ -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 </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} />;
}
+81
View File
@@ -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 />;
}
+88
View File
@@ -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 (15)"
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} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { SeoPageForm } from "../page-form";
export default function NewSeoPage() {
return <SeoPageForm />;
}
+70
View File
@@ -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>
);
}
+175
View File
@@ -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>
);
}
+46
View File
@@ -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");
}
+56
View File
@@ -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>
);
}
+29
View File
@@ -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
View File
@@ -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>
);
+24
View File
@@ -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>
);
}
+202
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+1
View File
@@ -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" },
];
+50
View File
@@ -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>
);
}
+405
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+13
View File
@@ -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",
+1
View File
@@ -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",