"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 & 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(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 & 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 (
{/* Top toolbar — always visible, fixed */}
editor.chain().focus().toggleBold().run()} icon={Bold} label="Kalın (⌘B)" /> editor.chain().focus().toggleItalic().run()} icon={Italic} label="İtalik (⌘I)" /> editor.chain().focus().toggleUnderline().run()} icon={UnderlineIcon} label="Altı çizili" /> editor.chain().focus().toggleHeading({ level: 1 }).run() } icon={Heading1} label="Başlık 1" /> editor.chain().focus().toggleHeading({ level: 2 }).run() } icon={Heading2} label="Başlık 2" /> editor.chain().focus().toggleHeading({ level: 3 }).run() } icon={Heading3} label="Başlık 3" /> editor.chain().focus().toggleBulletList().run()} icon={List} label="Madde listesi" /> editor.chain().focus().toggleOrderedList().run()} icon={ListOrdered} label="Numaralı liste" /> editor.chain().focus().toggleBlockquote().run()} icon={Quote} label="Alıntı" /> editor.chain().focus().toggleCodeBlock().run()} icon={Code} label="Kod" /> { 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ı" /> setImageModal(true)} icon={ImageIcon} label="Görsel ekle" /> editor.chain().focus().undo().run()} icon={Undo} label="Geri al" /> editor.chain().focus().redo().run()} icon={Redo} label="İleri al" />
{showSlash && filteredBlocks.length > 0 && (

Blok seç

{filteredBlocks.map((b, i) => { const Icon = b.icon; return ( ); })}
)}
{/* Footer info */}

‹/› ile blok ekle • Seçili metin için araç çubuğu otomatik açılır

{imageModal && ( setImageModal(false)} onPick={(url) => { editor.chain().focus().setImage({ src: url }).run(); setImageModal(false); }} /> )}
); } function ToolbarButton({ icon: Icon, label, active, onClick, }: { icon: LucideIcon; label: string; active?: boolean; onClick: () => void; }) { return ( ); } function Divider() { return
; } // ─── 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(null); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const inputRef = useRef(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 (
e.stopPropagation()} >

Görsel ekle

{ const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ""; }} />
{uploading && (
)}

Kütüphane

{!files ? (

Yükleniyor…

) : files.length === 0 ? (

Kütüphane boş. Yeni görsel yükleyin.

) : (
{files.map((f) => ( ))}
)}
); }