Files
portfolio/components/ContentManager.tsx
2026-01-12 14:36:10 +00:00

415 lines
16 KiB
TypeScript

'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EditorContent, useEditor, type JSONContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import { TextStyle } from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import Highlight from '@tiptap/extension-highlight';
import { Bold, Italic, Underline as UnderlineIcon, List, ListOrdered, Link as LinkIcon, Highlighter, Type, Save, RefreshCw } from 'lucide-react';
import { FontFamily, type AllowedFontFamily } from '@/lib/tiptap/fontFamily';
const EMPTY_DOC: JSONContent = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }],
};
type PageListItem = {
id: number;
key: string;
translations: Array<{ locale: string; updatedAt: string; title: string | null; slug: string | null }>;
};
export default function ContentManager() {
const [pages, setPages] = useState<PageListItem[]>([]);
const [selectedKey, setSelectedKey] = useState<string>('privacy-policy');
const [selectedLocale, setSelectedLocale] = useState<string>('de');
const [title, setTitle] = useState<string>('');
const [slug, setSlug] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [fontFamily, setFontFamily] = useState<AllowedFontFamily | ''>('');
const [color, setColor] = useState<string>('#111827');
const extensions = useMemo(
() => [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}),
TextStyle,
FontFamily,
Color,
Highlight,
],
[],
);
const editor = useEditor({
extensions,
content: EMPTY_DOC,
editorProps: {
attributes: {
class:
'prose prose-stone max-w-none focus:outline-none min-h-[320px] p-4 bg-white rounded-xl border border-stone-200',
},
},
});
const sessionHeaders = () => {
const sessionToken = sessionStorage.getItem('admin_session_token') || '';
return {
'x-admin-request': 'true',
'x-session-token': sessionToken,
'Content-Type': 'application/json',
};
};
const loadPages = useCallback(async () => {
setError('');
try {
setIsLoading(true);
const res = await fetch('/api/content/pages', { headers: sessionHeaders() });
const data = await res.json();
if (!res.ok) throw new Error(data?.error || 'Failed to load content pages');
setPages(data.pages || []);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load content pages');
} finally {
setIsLoading(false);
}
}, []);
const loadSelected = useCallback(async () => {
if (!editor) return;
setError('');
try {
setIsLoading(true);
const res = await fetch(`/api/content/page?key=${encodeURIComponent(selectedKey)}&locale=${encodeURIComponent(selectedLocale)}`);
const data = await res.json();
const translation = data?.content;
const nextTitle = (translation?.title as string | undefined) || '';
const nextSlug = (translation?.slug as string | undefined) || '';
const nextDoc = (translation?.content as JSONContent | undefined) || EMPTY_DOC;
setTitle(nextTitle);
setSlug(nextSlug);
editor.commands.setContent(nextDoc);
setFontFamily('');
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load content');
} finally {
setIsLoading(false);
}
}, [editor, selectedKey, selectedLocale]);
useEffect(() => {
loadPages();
}, [loadPages]);
useEffect(() => {
loadSelected();
}, [loadSelected]);
const handleSave = async () => {
if (!editor) return;
setError('');
try {
setIsSaving(true);
const content = editor.getJSON();
const res = await fetch('/api/content/pages', {
method: 'POST',
headers: sessionHeaders(),
body: JSON.stringify({
key: selectedKey,
locale: selectedLocale,
title: title || null,
slug: slug || null,
content,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || 'Failed to save content');
await loadPages();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save content');
} finally {
setIsSaving(false);
}
};
const localeOptions = ['en', 'de'];
const fontOptions: Array<{ label: string; value: AllowedFontFamily | '' }> = [
{ label: 'Default', value: '' },
{ label: 'Inter', value: 'Inter' },
{ label: 'Sans', value: 'ui-sans-serif' },
{ label: 'Serif', value: 'ui-serif' },
{ label: 'Mono', value: 'ui-monospace' },
];
const selectedInfo = useMemo(() => {
const page = pages.find((p) => p.key === selectedKey);
const tr = page?.translations?.find((t) => t.locale === selectedLocale);
return tr;
}, [pages, selectedKey, selectedLocale]);
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-stone-900">Content Manager</h2>
<p className="text-stone-500 mt-1">
Edit texts/pages with rich formatting (bold, underline, links, highlights).
</p>
</div>
<button
onClick={loadPages}
className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded-lg hover:bg-stone-200 transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</button>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-100 rounded-xl text-red-700 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-4">
<div className="bg-white border border-stone-200 rounded-xl p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Page key</label>
<select
value={selectedKey}
onChange={(e) => setSelectedKey(e.target.value)}
className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
>
{pages.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
{pages.length === 0 && (
<>
<option value="privacy-policy">privacy-policy</option>
<option value="legal-notice">legal-notice</option>
<option value="home-hero">home-hero</option>
</>
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Locale</label>
<select
value={selectedLocale}
onChange={(e) => setSelectedLocale(e.target.value)}
className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
>
{localeOptions.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</div>
<div className="text-xs text-stone-500">
Last updated:{' '}
<span className="font-medium text-stone-700">
{selectedInfo?.updatedAt ? new Date(selectedInfo.updatedAt).toLocaleString() : '—'}
</span>
</div>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Title (optional)</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
placeholder="Page title"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Slug (optional)</label>
<input
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
placeholder="privacy-policy"
/>
</div>
<button
onClick={handleSave}
disabled={isSaving || isLoading || !editor}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-stone-900 text-stone-50 rounded-lg hover:bg-stone-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Save className="w-4 h-4" />
<span>{isSaving ? 'Saving…' : 'Save'}</span>
</button>
</div>
</div>
<div className="lg:col-span-2">
<div className="bg-white border border-stone-200 rounded-xl p-4">
<div className="text-sm font-semibold text-stone-900 mb-3">Content</div>
{isLoading ? (
<div className="text-stone-500 text-sm">Loading</div>
) : (
<>
{editor && (
<div className="flex flex-wrap items-center gap-2 mb-3">
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('bold')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Bold"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('italic')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Italic"
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('underline')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Underline"
>
<UnderlineIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHighlight().run()}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('highlight')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Highlight"
>
<Highlighter className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('bulletList')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Bullet list"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('orderedList')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Ordered list"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => {
const prev = editor.getAttributes('link')?.href as string | undefined;
const href = prompt('Enter URL', prev || 'https://');
if (!href) return;
editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
}}
className={`p-2 rounded-lg border transition-colors ${
editor.isActive('link')
? 'bg-stone-900 text-stone-50 border-stone-900'
: 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
}`}
title="Link"
>
<LinkIcon className="w-4 h-4" />
</button>
<div className="flex items-center gap-2 ml-auto">
<Type className="w-4 h-4 text-stone-500" />
<select
value={fontFamily}
onChange={(e) => {
const next = e.target.value as AllowedFontFamily | '';
setFontFamily(next);
if (!next) {
editor.chain().focus().unsetFontFamily().run();
} else {
editor.chain().focus().setFontFamily(next).run();
}
}}
className="px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300 text-sm"
title="Font family"
>
{fontOptions.map((f) => (
<option key={f.label} value={f.value}>
{f.label}
</option>
))}
</select>
<input
type="color"
value={color}
onChange={(e) => {
const next = e.target.value;
setColor(next);
editor.chain().focus().setColor(next).run();
}}
className="w-10 h-10 p-1 bg-white border border-stone-200 rounded-lg"
title="Text color"
/>
</div>
</div>
)}
<EditorContent editor={editor} />
</>
)}
<p className="text-xs text-stone-500 mt-3">
Tip: Use bold/underline, links, lists, headings. (Email-safe rendering is handled separately.)
</p>
</div>
</div>
</div>
</div>
);
}