deff889f0c
WordPress Gutenberg + Notion karışımı blok editor. 4 admin formunda markdown textarea yerine gerçek WYSIWYG editor. RichEditor component (components/admin/rich-editor.tsx): - TipTap v3 (@tiptap/react + starter-kit + link + image + placeholder + underline) - Üst toolbar (her zaman görünür): - B / I / U (bold, italic, underline) - H1 / H2 / H3 - Bullet list / Ordered list / Quote / Code block - Link (URL prompt) - Görsel ekle (MediaPicker modal) - Undo / Redo - Slash menu: '/' yazınca blok seçim menüsü açılır - Notion tarzı keyboard navigation (↓↑ Enter Esc) - 8 blok tipi: H1/H2/H3/ul/ol/quote/code/hr - Image picker modal (toolbar görsel butonundan) - Mevcut MediaPicker'ı kullanır - 'Yeni görsel yükle' (progress bar ile) + 'Kütüphaneden seç' grid - HTML çıktı (hidden input ile form'a) - Mevcut content alanlarıyla backward compat Formlarda değişiklik (4 dosya): - app/admin/(protected)/blog/form.tsx → content - app/admin/(protected)/hizmetler/form.tsx → content - app/admin/(protected)/projeler/form.tsx → content - app/admin/(protected)/sektorler/form.tsx → content Public render (lib/content-render.ts): - renderContent() yardımcısı: - İçerik '<' ile başlıyorsa → HTML (direkt döner) - Aksi halde → markdown (marked.parse) - 4 detay sayfası bu helper'ı kullanıyor (blog/[slug], projeler/[slug], hizmetler/[slug], sektor/[slug]) - Eski markdown içerikler hala çalışıyor, yeni içerikler HTML olarak gelir 37 route, build temiz.
575 lines
17 KiB
TypeScript
575 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useEditor, EditorContent } from "@tiptap/react";
|
||
import StarterKit from "@tiptap/starter-kit";
|
||
import Link from "@tiptap/extension-link";
|
||
import Image from "@tiptap/extension-image";
|
||
import Placeholder from "@tiptap/extension-placeholder";
|
||
import Underline from "@tiptap/extension-underline";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import {
|
||
Bold,
|
||
Italic,
|
||
Underline as UnderlineIcon,
|
||
Heading1,
|
||
Heading2,
|
||
Heading3,
|
||
List,
|
||
ListOrdered,
|
||
Quote,
|
||
Code,
|
||
Link as LinkIcon,
|
||
Image as ImageIcon,
|
||
Minus,
|
||
Undo,
|
||
Redo,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
|
||
interface BlockCommand {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
icon: LucideIcon;
|
||
command: (e: ReturnType<typeof useEditor> & object) => void;
|
||
}
|
||
|
||
const SLASH_BLOCKS: BlockCommand[] = [
|
||
{
|
||
id: "h1",
|
||
title: "Başlık 1",
|
||
description: "Büyük başlık",
|
||
icon: Heading1,
|
||
command: (e) => e.chain().focus().deleteRange({ from: 0, to: 0 }).toggleHeading({ level: 1 }).run(),
|
||
},
|
||
{
|
||
id: "h2",
|
||
title: "Başlık 2",
|
||
description: "Orta başlık",
|
||
icon: Heading2,
|
||
command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
|
||
},
|
||
{
|
||
id: "h3",
|
||
title: "Başlık 3",
|
||
description: "Küçük başlık",
|
||
icon: Heading3,
|
||
command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||
},
|
||
{
|
||
id: "ul",
|
||
title: "Madde listesi",
|
||
description: "Bullet list",
|
||
icon: List,
|
||
command: (e) => e.chain().focus().toggleBulletList().run(),
|
||
},
|
||
{
|
||
id: "ol",
|
||
title: "Numaralı liste",
|
||
description: "Ordered list",
|
||
icon: ListOrdered,
|
||
command: (e) => e.chain().focus().toggleOrderedList().run(),
|
||
},
|
||
{
|
||
id: "quote",
|
||
title: "Alıntı",
|
||
description: "Blockquote",
|
||
icon: Quote,
|
||
command: (e) => e.chain().focus().toggleBlockquote().run(),
|
||
},
|
||
{
|
||
id: "code",
|
||
title: "Kod bloğu",
|
||
description: "Code block",
|
||
icon: Code,
|
||
command: (e) => e.chain().focus().toggleCodeBlock().run(),
|
||
},
|
||
{
|
||
id: "hr",
|
||
title: "Ayırıcı",
|
||
description: "Yatay çizgi",
|
||
icon: Minus,
|
||
command: (e) => e.chain().focus().setHorizontalRule().run(),
|
||
},
|
||
];
|
||
|
||
export function RichEditor({
|
||
name,
|
||
defaultValue,
|
||
placeholder = "İçeriği yazmaya başlayın… `/` ile blok seç",
|
||
minHeight = 400,
|
||
}: {
|
||
name: string;
|
||
defaultValue?: string | null;
|
||
placeholder?: string;
|
||
minHeight?: number;
|
||
}) {
|
||
const [html, setHtml] = useState<string>(defaultValue ?? "");
|
||
const [showSlash, setShowSlash] = useState(false);
|
||
const [slashQuery, setSlashQuery] = useState("");
|
||
const [slashIndex, setSlashIndex] = useState(0);
|
||
const [imageModal, setImageModal] = useState(false);
|
||
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({
|
||
heading: { levels: [1, 2, 3] },
|
||
}),
|
||
Underline,
|
||
Link.configure({
|
||
openOnClick: false,
|
||
HTMLAttributes: { class: "text-[var(--sky-600)] underline" },
|
||
}),
|
||
Image.configure({
|
||
HTMLAttributes: { class: "rounded-xl my-4" },
|
||
}),
|
||
Placeholder.configure({ placeholder }),
|
||
],
|
||
content: defaultValue || "",
|
||
editorProps: {
|
||
attributes: {
|
||
class:
|
||
"prose prose-base max-w-none focus:outline-none px-6 py-6 min-h-[400px]",
|
||
},
|
||
},
|
||
onUpdate: ({ editor }) => {
|
||
setHtml(editor.getHTML());
|
||
},
|
||
immediatelyRender: false,
|
||
});
|
||
|
||
// Slash menu detection
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
const onUpdate = () => {
|
||
const { $from } = editor.state.selection;
|
||
const lineText = $from.parent.textContent;
|
||
const beforeCursor = lineText.slice(0, $from.parentOffset);
|
||
const match = beforeCursor.match(/\/(\w*)$/);
|
||
if (match) {
|
||
setShowSlash(true);
|
||
setSlashQuery(match[1].toLowerCase());
|
||
setSlashIndex(0);
|
||
} else {
|
||
setShowSlash(false);
|
||
}
|
||
};
|
||
editor.on("update", onUpdate);
|
||
editor.on("selectionUpdate", onUpdate);
|
||
return () => {
|
||
editor.off("update", onUpdate);
|
||
editor.off("selectionUpdate", onUpdate);
|
||
};
|
||
}, [editor]);
|
||
|
||
const filteredBlocks = SLASH_BLOCKS.filter(
|
||
(b) =>
|
||
!slashQuery ||
|
||
b.title.toLowerCase().includes(slashQuery) ||
|
||
b.id.includes(slashQuery),
|
||
);
|
||
|
||
// Slash menu keyboard
|
||
useEffect(() => {
|
||
if (!showSlash || !editor) return;
|
||
const handler = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
setShowSlash(false);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.key === "ArrowDown") {
|
||
setSlashIndex((i) => Math.min(i + 1, filteredBlocks.length - 1));
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.key === "ArrowUp") {
|
||
setSlashIndex((i) => Math.max(i - 1, 0));
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.key === "Enter") {
|
||
const block = filteredBlocks[slashIndex];
|
||
if (block) {
|
||
e.preventDefault();
|
||
// Delete the slash + query first
|
||
const { $from } = editor.state.selection;
|
||
const from = $from.pos - (slashQuery.length + 1);
|
||
editor.chain().focus().deleteRange({ from, to: $from.pos }).run();
|
||
block.command(
|
||
editor as unknown as ReturnType<typeof useEditor> & object,
|
||
);
|
||
setShowSlash(false);
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handler, true);
|
||
return () => window.removeEventListener("keydown", handler, true);
|
||
}, [showSlash, slashIndex, filteredBlocks, editor, slashQuery]);
|
||
|
||
if (!editor) return null;
|
||
|
||
return (
|
||
<div>
|
||
<input type="hidden" name={name} value={html} />
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
|
||
{/* Top toolbar — always visible, fixed */}
|
||
<div className="flex flex-wrap items-center gap-0.5 border-b border-[var(--border)] bg-[var(--navy-50)]/40 p-2">
|
||
<ToolbarButton
|
||
active={editor.isActive("bold")}
|
||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
icon={Bold}
|
||
label="Kalın (⌘B)"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("italic")}
|
||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
icon={Italic}
|
||
label="İtalik (⌘I)"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("underline")}
|
||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||
icon={UnderlineIcon}
|
||
label="Altı çizili"
|
||
/>
|
||
<Divider />
|
||
<ToolbarButton
|
||
active={editor.isActive("heading", { level: 1 })}
|
||
onClick={() =>
|
||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||
}
|
||
icon={Heading1}
|
||
label="Başlık 1"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("heading", { level: 2 })}
|
||
onClick={() =>
|
||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||
}
|
||
icon={Heading2}
|
||
label="Başlık 2"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("heading", { level: 3 })}
|
||
onClick={() =>
|
||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||
}
|
||
icon={Heading3}
|
||
label="Başlık 3"
|
||
/>
|
||
<Divider />
|
||
<ToolbarButton
|
||
active={editor.isActive("bulletList")}
|
||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||
icon={List}
|
||
label="Madde listesi"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("orderedList")}
|
||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||
icon={ListOrdered}
|
||
label="Numaralı liste"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("blockquote")}
|
||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||
icon={Quote}
|
||
label="Alıntı"
|
||
/>
|
||
<ToolbarButton
|
||
active={editor.isActive("codeBlock")}
|
||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||
icon={Code}
|
||
label="Kod"
|
||
/>
|
||
<Divider />
|
||
<ToolbarButton
|
||
onClick={() => {
|
||
const previousUrl = editor.getAttributes("link").href;
|
||
const url = window.prompt("URL", previousUrl ?? "https://");
|
||
if (url === null) return;
|
||
if (url === "") {
|
||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||
return;
|
||
}
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.extendMarkRange("link")
|
||
.setLink({ href: url })
|
||
.run();
|
||
}}
|
||
active={editor.isActive("link")}
|
||
icon={LinkIcon}
|
||
label="Bağlantı"
|
||
/>
|
||
<ToolbarButton
|
||
onClick={() => setImageModal(true)}
|
||
icon={ImageIcon}
|
||
label="Görsel ekle"
|
||
/>
|
||
<Divider />
|
||
<ToolbarButton
|
||
onClick={() => editor.chain().focus().undo().run()}
|
||
icon={Undo}
|
||
label="Geri al"
|
||
/>
|
||
<ToolbarButton
|
||
onClick={() => editor.chain().focus().redo().run()}
|
||
icon={Redo}
|
||
label="İleri al"
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<EditorContent editor={editor} style={{ minHeight }} />
|
||
|
||
{showSlash && filteredBlocks.length > 0 && (
|
||
<div className="absolute left-6 top-12 z-10 w-72 rounded-xl border border-[var(--border)] bg-white p-1 shadow-xl">
|
||
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||
Blok seç
|
||
</p>
|
||
{filteredBlocks.map((b, i) => {
|
||
const Icon = b.icon;
|
||
return (
|
||
<button
|
||
key={b.id}
|
||
type="button"
|
||
onMouseEnter={() => setSlashIndex(i)}
|
||
onClick={() => {
|
||
const { $from } = editor.state.selection;
|
||
const from = $from.pos - (slashQuery.length + 1);
|
||
editor
|
||
.chain()
|
||
.focus()
|
||
.deleteRange({ from, to: $from.pos })
|
||
.run();
|
||
b.command(
|
||
editor as unknown as ReturnType<typeof useEditor> &
|
||
object,
|
||
);
|
||
setShowSlash(false);
|
||
}}
|
||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
|
||
i === slashIndex
|
||
? "bg-[var(--navy-50)]"
|
||
: "hover:bg-[var(--navy-50)]/60"
|
||
}`}
|
||
>
|
||
<div className="flex size-8 items-center justify-center rounded-md bg-white text-[var(--navy)]">
|
||
<Icon className="size-4" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-[var(--navy)]">{b.title}</p>
|
||
<p className="text-[11px] text-[var(--muted)]">
|
||
{b.description}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* Footer info */}
|
||
<p className="mt-2 text-xs text-[var(--muted)]">
|
||
‹/› ile blok ekle • Seçili metin için araç çubuğu otomatik açılır
|
||
</p>
|
||
|
||
{imageModal && (
|
||
<ImagePickerModal
|
||
onClose={() => setImageModal(false)}
|
||
onPick={(url) => {
|
||
editor.chain().focus().setImage({ src: url }).run();
|
||
setImageModal(false);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolbarButton({
|
||
icon: Icon,
|
||
label,
|
||
active,
|
||
onClick,
|
||
}: {
|
||
icon: LucideIcon;
|
||
label: string;
|
||
active?: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
title={label}
|
||
aria-label={label}
|
||
className={`flex size-8 items-center justify-center rounded-md transition ${
|
||
active
|
||
? "bg-[var(--navy)] text-white"
|
||
: "text-[var(--muted)] hover:bg-white hover:text-[var(--navy)]"
|
||
}`}
|
||
>
|
||
<Icon className="size-4" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function Divider() {
|
||
return <div className="mx-1 h-5 w-px bg-[var(--border)]" />;
|
||
}
|
||
|
||
// ─── Image picker — uses media library API ───────────────
|
||
|
||
interface MediaFile {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
}
|
||
|
||
function ImagePickerModal({
|
||
onClose,
|
||
onPick,
|
||
}: {
|
||
onClose: () => void;
|
||
onPick: (url: string) => void;
|
||
}) {
|
||
const [files, setFiles] = useState<MediaFile[] | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/admin/media/list")
|
||
.then((r) => r.json())
|
||
.then((d) => setFiles(d.files || []))
|
||
.catch(() => setFiles([]));
|
||
}, []);
|
||
|
||
function handleFile(file: File) {
|
||
setUploading(true);
|
||
setProgress(0);
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open("POST", "/api/admin/media/upload");
|
||
xhr.upload.addEventListener("progress", (e) => {
|
||
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
|
||
});
|
||
xhr.onload = () => {
|
||
try {
|
||
const data = JSON.parse(xhr.responseText);
|
||
if (data.url) {
|
||
onPick(data.url);
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setUploading(false);
|
||
};
|
||
xhr.onerror = () => {
|
||
setUploading(false);
|
||
};
|
||
xhr.send(fd);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="w-full max-w-3xl rounded-2xl bg-white p-5 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="text-base font-semibold text-[var(--navy)]">
|
||
Görsel ekle
|
||
</h3>
|
||
|
||
<div className="mt-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => inputRef.current?.click()}
|
||
disabled={uploading}
|
||
className="rounded-full bg-[var(--navy)] px-4 py-2 text-xs font-medium text-white hover:bg-[var(--navy-700)] disabled:opacity-60"
|
||
>
|
||
{uploading ? `Yükleniyor… ${progress}%` : "Yeni görsel yükle"}
|
||
</button>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) handleFile(f);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{uploading && (
|
||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[var(--navy-50)]">
|
||
<div
|
||
className="h-full bg-[var(--sky)] transition-all"
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-5 max-h-[60vh] overflow-y-auto">
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||
Kütüphane
|
||
</p>
|
||
{!files ? (
|
||
<p className="py-8 text-center text-sm text-[var(--muted)]">
|
||
Yükleniyor…
|
||
</p>
|
||
) : files.length === 0 ? (
|
||
<p className="py-8 text-center text-sm text-[var(--muted)]">
|
||
Kütüphane boş. Yeni görsel yükleyin.
|
||
</p>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-5">
|
||
{files.map((f) => (
|
||
<button
|
||
key={f.id}
|
||
type="button"
|
||
onClick={() => onPick(f.url)}
|
||
className="aspect-square overflow-hidden rounded-lg border border-[var(--border)] transition hover:border-[var(--sky)]"
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={f.url}
|
||
alt={f.name}
|
||
className="size-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4 text-right">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-full border border-[var(--border)] bg-white px-4 py-2 text-xs font-medium text-[var(--muted)] hover:text-[var(--navy)]"
|
||
>
|
||
Kapat
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|