feat: WordPress tarzı rich editor (TipTap + slash menu + MediaPicker)
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.
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user