x
This commit is contained in:
1 parent
0d4df55d93
commit
ac08f4490b
1 file changed
+198
@@ -0,0 +1,198 @@
|
||||
import type React from 'react';
|
||||
import { Image as ImageIcon, Plus, Trash2, UploadCloud } from 'lucide-react';
|
||||
import { type PageSection } from '@homaroma/types';
|
||||
|
||||
type EditingSetter = React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
|
||||
type UploadTarget = { type: 'main' | 'slide' | 'gallery'; index?: number };
|
||||
|
||||
interface BaseProps {
|
||||
editingSection: Partial<PageSection>;
|
||||
setEditingSection: EditingSetter;
|
||||
editingLang: string;
|
||||
getLocalizedValue: (payload: unknown, field: string) => string;
|
||||
updateLocalizedValue: (field: string, value: string) => void;
|
||||
updatePayloadValue: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
interface MediaProps {
|
||||
setUploadTarget: (target: UploadTarget | null) => void;
|
||||
setIsMediaModalOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function localizedValue(record: Record<string, unknown>, field: string, lang: string) {
|
||||
const value = record[field];
|
||||
if (typeof value === 'string') return lang === 'th' ? value : '';
|
||||
if (value && typeof value === 'object') return String((value as Record<string, unknown>)[lang] || '');
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateArrayItem(
|
||||
setEditingSection: EditingSetter,
|
||||
field: string,
|
||||
index: number,
|
||||
updater: (item: Record<string, unknown>) => Record<string, unknown>,
|
||||
) {
|
||||
setEditingSection((previous) => {
|
||||
if (!previous) return null;
|
||||
const payload = previous.payload || {};
|
||||
const items = Array.isArray(payload[field]) ? [...payload[field]] : [];
|
||||
items[index] = updater((items[index] || {}) as Record<string, unknown>);
|
||||
return { ...previous, payload: { ...payload, [field]: items } };
|
||||
});
|
||||
}
|
||||
|
||||
function updateItemLocalized(
|
||||
setEditingSection: EditingSetter,
|
||||
field: string,
|
||||
index: number,
|
||||
itemField: string,
|
||||
lang: string,
|
||||
value: string,
|
||||
) {
|
||||
updateArrayItem(setEditingSection, field, index, (item) => ({
|
||||
...item,
|
||||
[itemField]: {
|
||||
...(typeof item[itemField] === 'object' && item[itemField] ? item[itemField] as Record<string, unknown> : {}),
|
||||
[lang]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function removeArrayItem(setEditingSection: EditingSetter, field: string, index: number) {
|
||||
setEditingSection((previous) => {
|
||||
if (!previous) return null;
|
||||
const payload = previous.payload || {};
|
||||
const items = Array.isArray(payload[field]) ? payload[field].filter((_, itemIndex) => itemIndex !== index) : [];
|
||||
return { ...previous, payload: { ...payload, [field]: items } };
|
||||
});
|
||||
}
|
||||
|
||||
export function ArticleHeaderForm({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
setUploadTarget,
|
||||
setIsMediaModalOpen,
|
||||
}: BaseProps & MediaProps) {
|
||||
const payload = editingSection.payload || {};
|
||||
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '';
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{['title', 'subtitle', 'category', 'author'].map((field) => (
|
||||
<label key={field} className={`space-y-2 ${field === 'title' || field === 'subtitle' ? 'md:col-span-2' : ''}`}>
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-slate-400">{field}</span>
|
||||
<input
|
||||
required={field === 'title'}
|
||||
value={getLocalizedValue(payload, field)}
|
||||
onChange={(event) => updateLocalizedValue(field, event.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="space-y-2">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-slate-400">Publish date</span>
|
||||
<input type="date" value={typeof payload.publishDate === 'string' ? payload.publishDate : ''} onChange={(event) => updatePayloadValue('publishDate', event.target.value)} className="admin-input w-full" />
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-slate-400">Image alt</span>
|
||||
<input required={Boolean(imageUrl)} value={getLocalizedValue(payload, 'imageAlt')} onChange={(event) => updateLocalizedValue('imageAlt', event.target.value)} className="admin-input w-full" />
|
||||
</label>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cover image</span>
|
||||
<button type="button" className="group relative flex h-48 w-full items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-night-950/50" onClick={() => { setUploadTarget({ type: 'main' }); setIsMediaModalOpen(true); }}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" className="h-full w-full object-cover" /> : <ImageIcon className="h-10 w-10 text-slate-700" />}
|
||||
<UploadCloud className="absolute h-9 w-9 text-white opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleGalleryForm({ editingSection, setEditingSection, editingLang, updatePayloadValue, setUploadTarget, setIsMediaModalOpen }: BaseProps & MediaProps) {
|
||||
const payload = editingSection.payload || {};
|
||||
const images = Array.isArray(payload.images) ? payload.images as Record<string, unknown>[] : [];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Layout</span>
|
||||
<select value={typeof payload.layout === 'string' ? payload.layout : 'grid'} onChange={(event) => updatePayloadValue('layout', event.target.value)} className="admin-input w-full">
|
||||
<option value="grid">Grid</option>
|
||||
<option value="featured">Featured</option>
|
||||
</select>
|
||||
</label>
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="grid grid-cols-1 gap-4 border-t border-white/10 pt-6 md:grid-cols-2">
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<button type="button" className="flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-night-950/40" onClick={() => { setUploadTarget({ type: 'gallery', index }); setIsMediaModalOpen(true); }}>
|
||||
{typeof image.imageUrl === 'string' && image.imageUrl ? <img src={image.imageUrl} alt="" className="h-full w-full object-cover" /> : <ImageIcon className="h-9 w-9 text-slate-700" />}
|
||||
</button>
|
||||
</div>
|
||||
<label className="space-y-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Image URL</span>
|
||||
<input value={typeof image.imageUrl === 'string' ? image.imageUrl : ''} onChange={(event) => updateArrayItem(setEditingSection, 'images', index, (item) => ({ ...item, imageUrl: event.target.value }))} className="admin-input w-full" />
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Alt text</span>
|
||||
<input required value={localizedValue(image, 'alt', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'images', index, 'alt', editingLang, event.target.value)} className="admin-input w-full" />
|
||||
</label>
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Caption</span>
|
||||
<input value={localizedValue(image, 'caption', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'images', index, 'caption', editingLang, event.target.value)} className="admin-input w-full" />
|
||||
</label>
|
||||
<button type="button" onClick={() => removeArrayItem(setEditingSection, 'images', index)} className="inline-flex items-center gap-2 text-sm text-red-400"><Trash2 size={16} /> Remove image</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" disabled={images.length >= 8} onClick={() => updatePayloadValue('images', [...images, { imageUrl: '', alt: { [editingLang]: '' } }])} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-40"><Plus size={16} /> Add image</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleFactsForm({ editingSection, setEditingSection, editingLang, getLocalizedValue, updateLocalizedValue, updatePayloadValue }: BaseProps) {
|
||||
const payload = editingSection.payload || {};
|
||||
const items = Array.isArray(payload.items) ? payload.items as Record<string, unknown>[] : [];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Section title</span><input value={getLocalizedValue(payload, 'title')} onChange={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" /></label>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="grid gap-4 border-t border-white/10 pt-5 md:grid-cols-2">
|
||||
<input required value={localizedValue(item, 'title', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'items', index, 'title', editingLang, event.target.value)} className="admin-input w-full" placeholder="Fact title" />
|
||||
<input value={localizedValue(item, 'description', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'items', index, 'description', editingLang, event.target.value)} className="admin-input w-full" placeholder="Description" />
|
||||
<button type="button" onClick={() => removeArrayItem(setEditingSection, 'items', index)} className="inline-flex items-center gap-2 text-sm text-red-400"><Trash2 size={16} /> Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" disabled={items.length >= 8} onClick={() => updatePayloadValue('items', [...items, { title: { [editingLang]: '' } }])} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-40"><Plus size={16} /> Add fact</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleVideoForm({ editingSection, getLocalizedValue, updateLocalizedValue, updatePayloadValue }: BaseProps) {
|
||||
const payload = editingSection.payload || {};
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<label className="space-y-2 md:col-span-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Title</span><input value={getLocalizedValue(payload, 'title')} onChange={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" /></label>
|
||||
<label className="space-y-2 md:col-span-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Video URL</span><input type="url" required value={typeof payload.videoUrl === 'string' ? payload.videoUrl : ''} onChange={(event) => updatePayloadValue('videoUrl', event.target.value)} className="admin-input w-full" /></label>
|
||||
<label className="space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Caption</span><input value={getLocalizedValue(payload, 'caption')} onChange={(event) => updateLocalizedValue('caption', event.target.value)} className="admin-input w-full" /></label>
|
||||
<label className="space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Aspect ratio</span><select value={typeof payload.aspectRatio === 'string' ? payload.aspectRatio : '16:9'} onChange={(event) => updatePayloadValue('aspectRatio', event.target.value)} className="admin-input w-full"><option value="16:9">16:9</option><option value="4:3">4:3</option><option value="1:1">1:1</option></select></label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleReferencesForm({ editingSection, setEditingSection, editingLang, getLocalizedValue, updateLocalizedValue, updatePayloadValue }: BaseProps) {
|
||||
const payload = editingSection.payload || {};
|
||||
const items = Array.isArray(payload.items) ? payload.items as Record<string, unknown>[] : [];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Section title</span><input value={getLocalizedValue(payload, 'title')} onChange={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" /></label>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="grid gap-4 border-t border-white/10 pt-5 md:grid-cols-2">
|
||||
<input required value={localizedValue(item, 'title', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'items', index, 'title', editingLang, event.target.value)} className="admin-input w-full" placeholder="Reference title" />
|
||||
<input type="url" value={typeof item.url === 'string' ? item.url : ''} onChange={(event) => updateArrayItem(setEditingSection, 'items', index, (record) => ({ ...record, url: event.target.value || undefined }))} className="admin-input w-full" placeholder="https://..." />
|
||||
<input value={localizedValue(item, 'note', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'items', index, 'note', editingLang, event.target.value)} className="admin-input w-full md:col-span-2" placeholder="Optional note" />
|
||||
<button type="button" onClick={() => removeArrayItem(setEditingSection, 'items', index)} className="inline-flex items-center gap-2 text-sm text-red-400"><Trash2 size={16} /> Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" disabled={items.length >= 20} onClick={() => updatePayloadValue('items', [...items, { title: { [editingLang]: '' } }])} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-40"><Plus size={16} /> Add reference</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user