This commit is contained in:
Your Name committed 2026-07-24 19:39:45 -04:00
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>
);
}