feat: SEO altyapısı + admin editör/favicon/menü düzeltmeleri
Admin & site: - @tailwindcss/typography ekle → editör ve yayın içeriği prose stilleriyle düzgün render - Favicon: logo.png'den kare app/icon.png + apple-icon.png, varsayılan favicon.ico kaldırıldı - SEO keyword: seo_settings.default_keywords + seo_pages.keywords + buildMetadata birleştirme - Menü düzeni admin'den yönetilebilir (site_settings.nav_items, /admin/menu, header & mobile-menu refactor) SEO: - app/sitemap.ts (statik + blog/hizmet/çözüm/proje/sektör dinamik) - app/robots.ts (sitemap ref + /admin,/api disallow) - app/llms.txt/route.ts (AI/LLM rehberi) - BlogPosting/Service/FAQ/Article JSON-LD wire (json-ld bileşenleri bağlandı) - buildMetadata: blog/proje OG görseli + type article + keywords birleştirme düzeltmesi - blog tags → keyword
This commit is contained in:
@@ -40,3 +40,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# SEO audit çıktıları (repoya girmesin)
|
||||
seo-audit/
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Calendar } from "lucide-react";
|
||||
import { renderContent } from "@/lib/content-render";
|
||||
import { getPostBySlug } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import { BlogPostingLd } from "@/components/json-ld";
|
||||
import { ContentSidebar } from "@/components/content-sidebar";
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -19,6 +20,7 @@ export async function generateMetadata({
|
||||
return buildMetadata(`/blog/${slug}`, {
|
||||
title: post.seo_title || post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
keywords: post.tags ?? undefined,
|
||||
openGraph: {
|
||||
title: post.seo_title || post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
@@ -44,6 +46,7 @@ export default async function BlogPostPage({
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-16">
|
||||
<BlogPostingLd post={post} />
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SectionTitle } from "@/components/section-title";
|
||||
import { FaqList } from "@/components/faq-list";
|
||||
import { ServiceHero } from "@/components/service-hero";
|
||||
import { ServiceSidebar } from "@/components/service-sidebar";
|
||||
import { ServiceLd, FaqLd } from "@/components/json-ld";
|
||||
import type { FaqItem } from "@/lib/types";
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -59,6 +60,8 @@ export default async function ServiceDetailPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServiceLd service={service} settings={settings} />
|
||||
<FaqLd items={faqItems} />
|
||||
<ServiceHero service={service} settings={settings} />
|
||||
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.5fr_1fr]">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { renderContent } from "@/lib/content-render";
|
||||
import { getProjectBySlug, listProjects } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import { Gallery } from "@/components/gallery";
|
||||
import { ArticleLd } from "@/components/json-ld";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import type { ProjectMetric } from "@/lib/types";
|
||||
|
||||
@@ -74,6 +75,7 @@ export default async function ProjectDetailPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArticleLd post={project} />
|
||||
<section className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto max-w-7xl px-6 py-12">
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
FormActions,
|
||||
FormShell,
|
||||
PrimaryButton,
|
||||
} from "@/components/admin/form";
|
||||
import { saveNavMenu } from "@/lib/admin-actions";
|
||||
import { serializeNavItems, type NavItem } from "@/lib/nav";
|
||||
|
||||
export function MenuForm({ initial }: { initial: NavItem[] }) {
|
||||
const [items, setItems] = useState<NavItem[]>(initial);
|
||||
|
||||
function move(index: number, dir: -1 | 1) {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVisible(index: number) {
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === index ? { ...it, visible: !it.visible } : it,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setLabel(index: number, value: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) => (i === index ? { ...it, label: value } : it)),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = serializeNavItems(
|
||||
items.map((i) => ({ key: i.key, visible: i.visible, label: i.label })),
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={saveNavMenu}>
|
||||
<input type="hidden" name="nav_items" value={payload} />
|
||||
<FormShell>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={`flex items-center gap-3 rounded-xl border border-[var(--border)] bg-white px-3 py-2.5 ${
|
||||
item.visible ? "" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<GripVertical className="size-4 shrink-0 text-[var(--muted)]" />
|
||||
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Yukarı taşı"
|
||||
className="text-[var(--muted)] transition hover:text-[var(--navy)] disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === items.length - 1}
|
||||
aria-label="Aşağı taşı"
|
||||
className="text-[var(--muted)] transition hover:text-[var(--navy)] disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={item.label}
|
||||
onChange={(e) => setLabel(i, e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--border)] bg-white px-3 py-1.5 text-sm outline-none transition focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs text-[var(--muted)]">
|
||||
{item.href}
|
||||
</span>
|
||||
|
||||
{item.mega && (
|
||||
<span className="shrink-0 rounded-full bg-[var(--navy-50)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--navy)]">
|
||||
Mega menü
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleVisible(i)}
|
||||
aria-label={item.visible ? "Gizle" : "Göster"}
|
||||
title={item.visible ? "Menüde görünür" : "Menüde gizli"}
|
||||
className={`inline-flex size-8 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
item.visible
|
||||
? "bg-[var(--navy)] text-white"
|
||||
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
}`}
|
||||
>
|
||||
{item.visible ? (
|
||||
<Eye className="size-4" />
|
||||
) : (
|
||||
<EyeOff className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 text-xs text-[var(--muted)]">
|
||||
Sıralama ok tuşlarıyla değişir. Göz simgesiyle bir öğeyi menüden
|
||||
gizleyebilirsiniz. “Hizmetler” öğesi mega menü olarak
|
||||
açılır.
|
||||
</p>
|
||||
|
||||
<FormActions>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Menüyü kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { getSiteSettings } from "@/lib/data";
|
||||
import { resolveNavItems } from "@/lib/nav";
|
||||
import { MenuForm } from "./form";
|
||||
|
||||
export default async function MenuAdminPage() {
|
||||
const settings = await getSiteSettings();
|
||||
const items = resolveNavItems(settings?.nav_items);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Menü düzeni"
|
||||
description="Üst menü öğelerinin sırasını ve görünürlüğünü düzenleyin. Etiketi boş bırakırsanız varsayılan kullanılır."
|
||||
/>
|
||||
<MenuForm initial={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,14 @@ export function SeoPageForm({ row }: { row?: SeoPageRow }) {
|
||||
rows={3}
|
||||
defaultValue={row?.description}
|
||||
/>
|
||||
<Textarea
|
||||
label="Anahtar kelimeler"
|
||||
name="keywords"
|
||||
rows={2}
|
||||
defaultValue={row?.keywords}
|
||||
placeholder="web tasarım, kurumsal site, kocaeli"
|
||||
help="Virgülle ayırın. Bu sayfaya özel; site geneli kelimelerle birleştirilir."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="OG görseli"
|
||||
name="og_image"
|
||||
|
||||
@@ -97,6 +97,17 @@ export default async function SeoAdminPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Anahtar kelimeler (site geneli)"
|
||||
name="default_keywords"
|
||||
rows={2}
|
||||
defaultValue={settings?.default_keywords}
|
||||
placeholder="yazılım geliştirme, web tasarım kocaeli, crm çözümleri, e-ticaret izmit"
|
||||
help="Virgülle ayırın. Tüm sayfalarda varsayılan olarak kullanılır; sayfa override ile birleştirilir."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormActions>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Global ayarları kaydet
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
+2
-1
@@ -30,7 +30,8 @@ export const metadata: Metadata = {
|
||||
locale: "tr_TR",
|
||||
type: "website",
|
||||
},
|
||||
icons: { icon: "/logo.png" },
|
||||
// Favicon/app ikonları app/icon.png + app/apple-icon.png dosya
|
||||
// konvansiyonundan otomatik üretilir (logo.png'den kare kırpıldı).
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import {
|
||||
getSeoSettings,
|
||||
getSiteSettings,
|
||||
listIndustries,
|
||||
listProjects,
|
||||
listPublishedPosts,
|
||||
listServices,
|
||||
listSolutions,
|
||||
} from "@/lib/data";
|
||||
|
||||
// AI/LLM'lerin (ChatGPT, Perplexity, Claude, Google AI Overviews vb.) siteyi
|
||||
// hızlı ve doğru anlaması için /llms.txt rehberi.
|
||||
// Spec: https://llmstxt.org
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
const BASE = siteConfig.url;
|
||||
|
||||
function section(title: string, lines: string[]): string {
|
||||
if (lines.length === 0) return "";
|
||||
return `## ${title}\n\n${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [seo, site, services, solutions, posts, industries] = await Promise.all([
|
||||
getSeoSettings(),
|
||||
getSiteSettings(),
|
||||
listServices(),
|
||||
listSolutions(),
|
||||
listPublishedPosts({ limit: 30 }),
|
||||
listIndustries(),
|
||||
]);
|
||||
|
||||
const name = seo?.site_name || siteConfig.name;
|
||||
const summary =
|
||||
seo?.site_description || site?.footer_tagline || siteConfig.tagline;
|
||||
const phone = site?.contact_phone || siteConfig.contact.phone;
|
||||
const email = site?.contact_email || siteConfig.contact.email;
|
||||
const address = site?.contact_address || siteConfig.contact.address;
|
||||
|
||||
const link = (title: string, path: string, desc?: string) =>
|
||||
`- [${title}](${BASE}${path})${desc ? `: ${desc}` : ""}`;
|
||||
|
||||
const body = [
|
||||
`# ${name}`,
|
||||
"",
|
||||
`> ${summary}`,
|
||||
"",
|
||||
`${name}, Kocaeli/İzmit merkezli; yazılım geliştirme, web tasarım, e-ticaret, mobil uygulama, CRM ve dijital pazarlama hizmetleri sunar. İletişim: ${phone} · ${email} · ${address}`,
|
||||
"",
|
||||
section("Ana Sayfalar", [
|
||||
link("Anasayfa", "/"),
|
||||
link("Hizmetler", "/hizmetler", "Tüm hizmetlerin listesi"),
|
||||
link("Çözümler", "/cozumler", "Paket çözümler"),
|
||||
link("Projeler", "/projeler", "Portföy ve vaka çalışmaları"),
|
||||
link("Blog", "/blog", "Rehber içerikler ve yazılar"),
|
||||
link("Hakkımızda", "/hakkimizda"),
|
||||
link("İletişim", "/iletisim"),
|
||||
]),
|
||||
section(
|
||||
"Hizmetler",
|
||||
services.map((s) =>
|
||||
link(s.title, `/hizmetler/${s.slug}`, s.description ?? undefined),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"Çözümler",
|
||||
solutions.map((s) =>
|
||||
link(s.title, `/cozumler/${s.slug}`, s.description ?? undefined),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"Sektörler",
|
||||
industries.map((i) =>
|
||||
link(i.title, `/sektor/${i.slug}`, i.subtitle ?? undefined),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"Blog Yazıları",
|
||||
posts.map((p) =>
|
||||
link(p.title, `/blog/${p.slug}`, p.excerpt ?? undefined),
|
||||
),
|
||||
),
|
||||
section("Kaynaklar", [link("Site haritası", "/sitemap.xml")]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin", "/api"],
|
||||
},
|
||||
sitemap: `${siteConfig.url}/sitemap.xml`,
|
||||
host: siteConfig.url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import {
|
||||
listIndustries,
|
||||
listProjects,
|
||||
listPublishedPosts,
|
||||
listServices,
|
||||
listSolutions,
|
||||
} from "@/lib/data";
|
||||
|
||||
const BASE = siteConfig.url;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const [posts, services, solutions, projects, industries] = await Promise.all([
|
||||
listPublishedPosts({ limit: 200 }),
|
||||
listServices(),
|
||||
listSolutions(),
|
||||
listProjects({ limit: 200 }),
|
||||
listIndustries(),
|
||||
]);
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{ url: `${BASE}/`, changeFrequency: "weekly", priority: 1 },
|
||||
{ url: `${BASE}/hizmetler`, changeFrequency: "monthly", priority: 0.9 },
|
||||
{ url: `${BASE}/cozumler`, changeFrequency: "monthly", priority: 0.9 },
|
||||
{ url: `${BASE}/projeler`, changeFrequency: "monthly", priority: 0.8 },
|
||||
{ url: `${BASE}/blog`, changeFrequency: "daily", priority: 0.8 },
|
||||
{ url: `${BASE}/hakkimizda`, changeFrequency: "yearly", priority: 0.6 },
|
||||
{ url: `${BASE}/iletisim`, changeFrequency: "yearly", priority: 0.7 },
|
||||
{ url: `${BASE}/site-analizi`, changeFrequency: "yearly", priority: 0.6 },
|
||||
{ url: `${BASE}/cerez-politikasi`, changeFrequency: "yearly", priority: 0.2 },
|
||||
];
|
||||
|
||||
const toEntry = (
|
||||
path: string,
|
||||
updatedAt?: string,
|
||||
priority = 0.7,
|
||||
): MetadataRoute.Sitemap[number] => ({
|
||||
url: `${BASE}${path}`,
|
||||
lastModified: updatedAt ? new Date(updatedAt) : undefined,
|
||||
changeFrequency: "weekly",
|
||||
priority,
|
||||
});
|
||||
|
||||
return [
|
||||
...staticRoutes,
|
||||
...posts.map((p) => toEntry(`/blog/${p.slug}`, p.$updatedAt, 0.7)),
|
||||
...services.map((s) => toEntry(`/hizmetler/${s.slug}`, s.$updatedAt, 0.8)),
|
||||
...solutions.map((s) => toEntry(`/cozumler/${s.slug}`, s.$updatedAt, 0.8)),
|
||||
...projects.map((p) => toEntry(`/projeler/${p.slug}`, p.$updatedAt, 0.7)),
|
||||
...industries.map((i) => toEntry(`/sektor/${i.slug}`, i.$updatedAt, 0.6)),
|
||||
];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Image as ImageIcon,
|
||||
Users as UsersIcon,
|
||||
Building2,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -24,6 +25,7 @@ type Item = { href: string; label: string; icon: LucideIcon };
|
||||
const items: Item[] = [
|
||||
{ href: "/admin", label: "Pano", icon: LayoutDashboard },
|
||||
{ href: "/admin/site", label: "Site Ayarları", icon: Settings },
|
||||
{ href: "/admin/menu", label: "Menü Düzeni", icon: ListOrdered },
|
||||
{ href: "/admin/blog", label: "Blog", icon: Newspaper },
|
||||
{ href: "/admin/hizmetler", label: "Hizmetler", icon: Layers },
|
||||
{ href: "/admin/cozumler", label: "Çözümler", icon: Boxes },
|
||||
|
||||
+78
-80
@@ -3,6 +3,8 @@ import Link from "next/link";
|
||||
import { ChevronDown, Phone } from "lucide-react";
|
||||
import { getSiteSettings, listServices } from "@/lib/data";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { resolveNavItems } from "@/lib/nav";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
import { HeaderScrollEffect } from "@/components/header-scroll";
|
||||
import { MobileMenu } from "@/components/mobile-menu";
|
||||
|
||||
@@ -22,6 +24,9 @@ export async function Header() {
|
||||
["seo-dijital-pazarlama", "sosyal-medya-yonetimi", "dijital-reklam"].includes(s.slug),
|
||||
);
|
||||
|
||||
// Admin'den düzenlenebilir üst menü düzeni
|
||||
const navItems = resolveNavItems(settings?.nav_items).filter((i) => i.visible);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderScrollEffect />
|
||||
@@ -50,22 +55,90 @@ export async function Header() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Col 2 — Desktop nav */}
|
||||
{/* Col 2 — Desktop nav (sıra admin'den yönetilir) */}
|
||||
<div className="hidden items-center gap-0.5 lg:flex">
|
||||
{navItems.map((item) =>
|
||||
item.mega ? (
|
||||
<ServicesMegaMenu
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
webServices={webServices}
|
||||
marketingServices={marketingServices}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Anasayfa
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Col 3 — CTA */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Phone — full mode (XL) */}
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
data-pill-hide="true"
|
||||
className="hidden h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 xl:inline-flex"
|
||||
aria-label={phone}
|
||||
>
|
||||
<Phone className="size-3.5" />
|
||||
<span>{phone}</span>
|
||||
</a>
|
||||
{/* "Ara" — pill mode'da görünür, kompakt */}
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
data-pill-show="true"
|
||||
className="hidden h-9 items-center gap-1.5 rounded-lg border border-gray-200 px-3 text-sm font-medium text-gray-700 transition-colors hover:border-[var(--navy)] hover:text-[var(--navy)]"
|
||||
aria-label={`${phone} - Ara`}
|
||||
style={{ display: "none" }}
|
||||
>
|
||||
<Phone className="size-3.5" />
|
||||
<span>Ara</span>
|
||||
</a>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="hidden h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)] sm:inline-flex"
|
||||
>
|
||||
Ücretsiz Teklif
|
||||
</Link>
|
||||
|
||||
{/* Hizmetler mega menu */}
|
||||
{/* Mobil menü (hamburger) — sadece < lg */}
|
||||
<MobileMenu
|
||||
navItems={navItems}
|
||||
services={services.map((s) => ({ slug: s.slug, title: s.title }))}
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesMegaMenu({
|
||||
label,
|
||||
webServices,
|
||||
marketingServices,
|
||||
}: {
|
||||
label: string;
|
||||
webServices: ServiceRow[];
|
||||
marketingServices: ServiceRow[];
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center justify-center gap-1 whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Hizmetler
|
||||
{label}
|
||||
<ChevronDown className="size-3 transition-transform duration-200 group-hover:rotate-180" />
|
||||
</button>
|
||||
<div className="pointer-events-none invisible absolute left-1/2 top-full z-50 w-[480px] -translate-x-1/2 pt-2 opacity-0 transition-all duration-150 ease-out group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100">
|
||||
@@ -111,80 +184,5 @@ export async function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/cozumler"
|
||||
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Çözümler
|
||||
</Link>
|
||||
<Link
|
||||
href="/projeler"
|
||||
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Projeler
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href="/hakkimizda"
|
||||
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Hakkımızda
|
||||
</Link>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
İletişim
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Col 3 — CTA */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Phone — full mode (XL) */}
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
data-pill-hide="true"
|
||||
className="hidden h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 xl:inline-flex"
|
||||
aria-label={phone}
|
||||
>
|
||||
<Phone className="size-3.5" />
|
||||
<span>{phone}</span>
|
||||
</a>
|
||||
{/* "Ara" — pill mode'da görünür, kompakt */}
|
||||
<a
|
||||
href={`tel:${phoneRaw}`}
|
||||
data-pill-show="true"
|
||||
className="hidden h-9 items-center gap-1.5 rounded-lg border border-gray-200 px-3 text-sm font-medium text-gray-700 transition-colors hover:border-[var(--navy)] hover:text-[var(--navy)]"
|
||||
aria-label={`${phone} - Ara`}
|
||||
style={{ display: "none" }}
|
||||
>
|
||||
<Phone className="size-3.5" />
|
||||
<span>Ara</span>
|
||||
</a>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="hidden h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)] sm:inline-flex"
|
||||
>
|
||||
Ücretsiz Teklif
|
||||
</Link>
|
||||
|
||||
{/* Mobil menü (hamburger) — sadece < lg */}
|
||||
<MobileMenu
|
||||
services={services.map((s) => ({ slug: s.slug, title: s.title }))}
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BlogPostRow,
|
||||
ProjectRow,
|
||||
ServiceRow,
|
||||
SiteSettingsRow,
|
||||
@@ -6,6 +7,12 @@ import type {
|
||||
} from "@/lib/types";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
function absUrl(url?: string | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("http")) return url;
|
||||
return `${siteConfig.url}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||
}
|
||||
|
||||
export function JsonLd({ data }: { data: object }) {
|
||||
return (
|
||||
<script
|
||||
@@ -137,6 +144,44 @@ export function BreadcrumbLd({
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogPostingLd({
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
post: BlogPostRow;
|
||||
settings?: SiteSettingsRow | null;
|
||||
}) {
|
||||
const image = absUrl(post.seo_image || post.cover_image) ?? `${siteConfig.url}/logo.png`;
|
||||
const url = `${siteConfig.url}/blog/${post.slug}`;
|
||||
const data: Record<string, unknown> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
image,
|
||||
datePublished: post.published_at ?? post.$createdAt,
|
||||
dateModified: post.$updatedAt,
|
||||
author: {
|
||||
"@type": post.author ? "Person" : "Organization",
|
||||
name: post.author || settings?.site_name || siteConfig.name,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: settings?.site_name ?? siteConfig.name,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${siteConfig.url}/logo.png`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: { "@type": "WebPage", "@id": url },
|
||||
url,
|
||||
};
|
||||
if (post.tags && post.tags.length > 0) {
|
||||
data.keywords = post.tags.join(", ");
|
||||
}
|
||||
return <JsonLd data={data} />;
|
||||
}
|
||||
|
||||
export function ArticleLd({ post }: { post: ProjectRow }) {
|
||||
return (
|
||||
<JsonLd
|
||||
|
||||
+17
-23
@@ -5,22 +5,17 @@ import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, X, ChevronDown, Phone, ArrowRight } from "lucide-react";
|
||||
import type { NavItem } from "@/lib/nav";
|
||||
|
||||
type NavService = { slug: string; title: string };
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/cozumler", label: "Çözümler" },
|
||||
{ href: "/projeler", label: "Projeler" },
|
||||
{ href: "/blog", label: "Blog" },
|
||||
{ href: "/hakkimizda", label: "Hakkımızda" },
|
||||
{ href: "/iletisim", label: "İletişim" },
|
||||
];
|
||||
|
||||
export function MobileMenu({
|
||||
navItems,
|
||||
services,
|
||||
phone,
|
||||
phoneRaw,
|
||||
}: {
|
||||
navItems: NavItem[];
|
||||
services: NavService[];
|
||||
phone: string;
|
||||
phoneRaw: string;
|
||||
@@ -84,15 +79,13 @@ export function MobileMenu({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Linkler */}
|
||||
{/* Linkler — sıra admin'den yönetilir */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="block rounded-xl px-4 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
Anasayfa
|
||||
</Link>
|
||||
|
||||
{navItems
|
||||
.filter((item) => item.visible)
|
||||
.map((item) =>
|
||||
item.mega ? (
|
||||
<div key={item.key}>
|
||||
{/* Hizmetler — açılır */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -100,7 +93,7 @@ export function MobileMenu({
|
||||
aria-expanded={servicesOpen}
|
||||
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
Hizmetler
|
||||
{item.label}
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform duration-200 ${
|
||||
servicesOpen ? "rotate-180" : ""
|
||||
@@ -126,16 +119,17 @@ export function MobileMenu({
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{LINKS.map((l) => (
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="block rounded-xl px-4 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
{l.label}
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Alt CTA */}
|
||||
|
||||
@@ -555,6 +555,34 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
revalidatePath("/admin/site");
|
||||
}
|
||||
|
||||
// ─── Navigation Menu ─────────────────────────────────────────────
|
||||
|
||||
export async function saveNavMenu(formData: FormData) {
|
||||
const secret = await requireSessionSecret();
|
||||
// Client form, sıralı menüyü JSON string olarak nav_items'a koyar.
|
||||
const navItems = str(formData.get("nav_items"));
|
||||
const data = { nav_items: navItems };
|
||||
try {
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.siteSettings,
|
||||
"homepage",
|
||||
data,
|
||||
secret,
|
||||
);
|
||||
} catch {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.siteSettings,
|
||||
"homepage",
|
||||
data,
|
||||
secret,
|
||||
);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
}
|
||||
|
||||
// ─── SEO Settings ────────────────────────────────────────────────
|
||||
|
||||
export async function saveSeoSettings(formData: FormData) {
|
||||
@@ -562,6 +590,7 @@ export async function saveSeoSettings(formData: FormData) {
|
||||
const data = {
|
||||
site_name: str(formData.get("site_name")),
|
||||
site_description: str(formData.get("site_description")),
|
||||
default_keywords: str(formData.get("default_keywords")),
|
||||
default_og_image: str(formData.get("default_og_image")),
|
||||
twitter_handle: str(formData.get("twitter_handle")),
|
||||
facebook_url: str(formData.get("facebook_url")),
|
||||
@@ -603,6 +632,7 @@ export async function saveSeoPage(formData: FormData) {
|
||||
path,
|
||||
title: str(formData.get("title")),
|
||||
description: str(formData.get("description")),
|
||||
keywords: str(formData.get("keywords")),
|
||||
og_image: str(formData.get("og_image")),
|
||||
canonical: str(formData.get("canonical")),
|
||||
noindex: bool(formData.get("noindex")),
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Üst menü (header + mobil) düzeni. Öğeler sabit bir kayıttan gelir; admin
|
||||
// panelinden yalnızca SIRA, GÖRÜNÜRLÜK ve (opsiyonel) ETİKET düzenlenir.
|
||||
// Bu modül hem sunucu (header) hem istemci (admin formu) tarafında import
|
||||
// edilebilir — bu yüzden "server-only" YOK.
|
||||
|
||||
export type NavKey =
|
||||
| "home"
|
||||
| "services"
|
||||
| "solutions"
|
||||
| "projects"
|
||||
| "blog"
|
||||
| "about"
|
||||
| "contact";
|
||||
|
||||
export interface NavRegistryEntry {
|
||||
key: NavKey;
|
||||
label: string; // varsayılan etiket
|
||||
href: string;
|
||||
mega?: boolean; // Hizmetler — mega menü olarak render edilir
|
||||
}
|
||||
|
||||
export const NAV_REGISTRY: Record<NavKey, NavRegistryEntry> = {
|
||||
home: { key: "home", label: "Anasayfa", href: "/" },
|
||||
services: { key: "services", label: "Hizmetler", href: "/hizmetler", mega: true },
|
||||
solutions: { key: "solutions", label: "Çözümler", href: "/cozumler" },
|
||||
projects: { key: "projects", label: "Projeler", href: "/projeler" },
|
||||
blog: { key: "blog", label: "Blog", href: "/blog" },
|
||||
about: { key: "about", label: "Hakkımızda", href: "/hakkimizda" },
|
||||
contact: { key: "contact", label: "İletişim", href: "/iletisim" },
|
||||
};
|
||||
|
||||
export const DEFAULT_NAV_ORDER: NavKey[] = [
|
||||
"home",
|
||||
"services",
|
||||
"solutions",
|
||||
"projects",
|
||||
"blog",
|
||||
"about",
|
||||
"contact",
|
||||
];
|
||||
|
||||
export interface NavItem {
|
||||
key: NavKey;
|
||||
label: string; // çözülmüş etiket (override veya varsayılan)
|
||||
href: string;
|
||||
mega: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface StoredNavItem {
|
||||
key: string;
|
||||
visible?: boolean;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* site_settings.nav_items içindeki JSON'ı kayıt ile birleştirir.
|
||||
* - Kayıtlı sıra önceliklidir, geçersiz/silinmiş key'ler atlanır.
|
||||
* - Kayıtta olmayan (yeni eklenen) öğeler varsayılan sırayla sona eklenir.
|
||||
* - JSON yoksa/bozuksa tam varsayılan menü döner.
|
||||
*/
|
||||
export function resolveNavItems(navItemsJson?: string | null): NavItem[] {
|
||||
let stored: StoredNavItem[] = [];
|
||||
if (navItemsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(navItemsJson);
|
||||
if (Array.isArray(parsed)) stored = parsed as StoredNavItem[];
|
||||
} catch {
|
||||
/* bozuk JSON — varsayılanlara düş */
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<NavKey>();
|
||||
const ordered: NavItem[] = [];
|
||||
|
||||
for (const item of stored) {
|
||||
const reg = NAV_REGISTRY[item.key as NavKey];
|
||||
if (!reg || seen.has(reg.key)) continue;
|
||||
seen.add(reg.key);
|
||||
ordered.push({
|
||||
key: reg.key,
|
||||
label: item.label?.trim() || reg.label,
|
||||
href: reg.href,
|
||||
mega: !!reg.mega,
|
||||
visible: item.visible !== false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of DEFAULT_NAV_ORDER) {
|
||||
if (seen.has(key)) continue;
|
||||
const reg = NAV_REGISTRY[key];
|
||||
ordered.push({
|
||||
key: reg.key,
|
||||
label: reg.label,
|
||||
href: reg.href,
|
||||
mega: !!reg.mega,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Admin formundan gelen düzeni depolanacak kompakt JSON'a çevirir. */
|
||||
export function serializeNavItems(
|
||||
items: { key: NavKey; visible: boolean; label?: string }[],
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
items.map((i) => {
|
||||
const reg = NAV_REGISTRY[i.key];
|
||||
const out: StoredNavItem = { key: i.key, visible: i.visible };
|
||||
// Sadece varsayılandan farklıysa etiketi sakla
|
||||
if (i.label && i.label.trim() && i.label.trim() !== reg.label) {
|
||||
out.label = i.label.trim();
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
);
|
||||
}
|
||||
+43
-2
@@ -19,17 +19,58 @@ export async function buildMetadata(path: string, fallback?: Metadata): Promise<
|
||||
override?.description ??
|
||||
(fallback?.description as string | undefined) ??
|
||||
siteDesc;
|
||||
const ogImage = override?.og_image || ogDefault;
|
||||
|
||||
// Sayfanın kendi OG bilgisi (blog kapağı, type:"article" vb.) — fallback'ten
|
||||
// oku. Öncelik: sayfa SEO override > sayfanın fallback OG görseli > varsayılan.
|
||||
const fbOg = fallback?.openGraph as
|
||||
| { images?: unknown; type?: string }
|
||||
| undefined;
|
||||
const fbOgImage = (() => {
|
||||
const imgs = fbOg?.images;
|
||||
if (typeof imgs === "string") return imgs;
|
||||
if (Array.isArray(imgs) && imgs.length) {
|
||||
const first = imgs[0];
|
||||
if (typeof first === "string") return first;
|
||||
if (first && typeof first === "object" && "url" in first)
|
||||
return String((first as { url: unknown }).url);
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const ogImage = override?.og_image || fbOgImage || ogDefault;
|
||||
const ogType = fbOg?.type ?? "website";
|
||||
|
||||
// Anahtar kelimeler: sayfa override + site geneli varsayılan + sayfanın kendi
|
||||
// keyword'leri (örn. blog etiketleri) birleştirilir, tekrarlar ayıklanır.
|
||||
const fbKeywords = fallback?.keywords;
|
||||
const fbKeywordsStr = Array.isArray(fbKeywords)
|
||||
? fbKeywords.join(",")
|
||||
: typeof fbKeywords === "string"
|
||||
? fbKeywords
|
||||
: "";
|
||||
const keywordsRaw = [override?.keywords, settings?.default_keywords, fbKeywordsStr]
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
const keywords = keywordsRaw
|
||||
? Array.from(
|
||||
new Set(
|
||||
keywordsRaw
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: ogImage ? [{ url: ogImage }] : undefined,
|
||||
type: "website",
|
||||
type: ogType as "website" | "article",
|
||||
locale: "tr_TR",
|
||||
siteName,
|
||||
},
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface SeoPageRow extends AwRow {
|
||||
path: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
keywords?: string | null; // virgülle ayrılmış anahtar kelimeler (sayfa override)
|
||||
og_image?: string | null;
|
||||
canonical?: string | null;
|
||||
noindex?: boolean | null;
|
||||
@@ -93,6 +94,7 @@ export interface SeoPageRow extends AwRow {
|
||||
export interface SeoSettingsRow extends AwRow {
|
||||
site_name?: string | null;
|
||||
site_description?: string | null;
|
||||
default_keywords?: string | null; // virgülle ayrılmış site geneli anahtar kelimeler
|
||||
default_og_image?: string | null;
|
||||
twitter_handle?: string | null;
|
||||
facebook_url?: string | null;
|
||||
@@ -152,6 +154,9 @@ export interface SiteSettingsRow extends AwRow {
|
||||
|
||||
footer_tagline?: string | null;
|
||||
|
||||
// Üst menü düzeni — JSON dizi: [{ key, visible, label? }] sırasıyla
|
||||
nav_items?: string | null;
|
||||
|
||||
whatsapp_message?: string | null;
|
||||
client_logos?: string[] | null;
|
||||
trust_items?: string[] | null; // JSON {"icon":"Star","value":"4.9","label":"..."}
|
||||
|
||||
Generated
+44
-1
@@ -8,6 +8,7 @@
|
||||
"name": "kovak-yazilim",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-image": "^3.23.5",
|
||||
"@tiptap/extension-link": "^3.23.5",
|
||||
"@tiptap/extension-placeholder": "^3.23.5",
|
||||
@@ -1083,6 +1084,18 @@
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.5.tgz",
|
||||
@@ -1622,6 +1635,18 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2128,6 +2153,19 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
@@ -2384,7 +2422,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -2437,6 +2474,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-image": "^3.23.5",
|
||||
"@tiptap/extension-link": "^3.23.5",
|
||||
"@tiptap/extension-placeholder": "^3.23.5",
|
||||
|
||||
Reference in New Issue
Block a user