Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
414
components/ContentManager.tsx
Normal file
414
components/ContentManager.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user