feat: add structured admin content and translation editors
This commit is contained in:
142
src/components/admin/AdminDashboard.tsx
Normal file
142
src/components/admin/AdminDashboard.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
import type { ContentSnapshot } from '@/server/content';
|
||||
import { entryKinds, entrySchemas, sectionSchemas, settingsSchema, languageSchema, type EntryKind, type SectionKey } from '@/content/contracts';
|
||||
import { entrySamples } from '@/content/samples';
|
||||
import { dictionaries, flattenMessages, messageDefaults } from '@/content/messages';
|
||||
import { Field, humanize, uploadFile, type FormSchema, type Issue, type Json, type Upload } from './Fields';
|
||||
|
||||
type Tab = 'settings' | 'sections' | EntryKind | 'languages' | 'media';
|
||||
const asJson = (value: unknown) => value as Json;
|
||||
const schemaFor = (schema: z.ZodType) => z.toJSONSchema(schema, { unrepresentable: 'any' }) as FormSchema;
|
||||
const labels: Record<Tab, string> = { settings: 'Site settings', sections: 'Profile & sections', projects: 'Projects', experience: 'Experience', skills: 'Skills & tools', education: 'Education', milestones: 'Milestones', languages: 'Languages', media: 'Media library' };
|
||||
const resources = ['settings', 'languages', 'sections', 'entries'] as const;
|
||||
|
||||
export function AdminDashboard({ initial, initialUploads }: { initial: ContentSnapshot; initialUploads: Upload[] }) {
|
||||
const router = useRouter();
|
||||
const [snapshot, setSnapshot] = useState(initial); const [uploads, setUploads] = useState(initialUploads);
|
||||
const [tab, setTab] = useState<Tab>('settings'); const [selected, setSelected] = useState('');
|
||||
const [dirty, setDirty] = useState(false); const [version, setVersion] = useState(0); const [notice, setNotice] = useState('');
|
||||
const onUpload = (item: Upload) => setUploads(previous => [...previous, item]);
|
||||
const navigate = (nextTab: Tab, id = '') => {
|
||||
if (dirty && !confirm('Discard your unsaved changes?')) return;
|
||||
setTab(nextTab); setSelected(id); setDirty(false); setNotice(''); setVersion(value => value + 1);
|
||||
};
|
||||
const refresh = async () => {
|
||||
const result = await Promise.all(resources.map(async resource => {
|
||||
const response = await fetch(`/api/admin/${resource}`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not reload your content. Refresh the page.');
|
||||
return [resource, await response.json()];
|
||||
}));
|
||||
setSnapshot(Object.fromEntries(result) as ContentSnapshot); setDirty(false); setVersion(value => value + 1);
|
||||
};
|
||||
const entries = snapshot.entries.filter(entry => entry.kind === tab);
|
||||
const choices = tab === 'sections' ? snapshot.sections.map(item => ({ id: item.key, title: humanize(item.key), visible: item.visible }))
|
||||
: tab === 'languages' ? snapshot.languages.map(item => ({ id: item.code, title: item.name, visible: item.enabled }))
|
||||
: entries.map(item => ({ id: item.id, title: String(item.shared.title || item.shared.label || item.shared.role || item.shared.degree || item.id), visible: item.visible }));
|
||||
const activeId = selected || choices[0]?.id || 'new';
|
||||
return <div className="admin-shell">
|
||||
<aside className="admin-sidebar"><a className="admin-brand" href="/" target="_blank" rel="noreferrer"><span className="brand-mark">P</span><span>Portfolio<br /><strong>Builder</strong></span></a><p className="nav-label">YOUR WEBSITE</p>
|
||||
<nav aria-label="Admin navigation">{Object.entries(labels).map(([key, label]) => <button key={key} type="button" aria-current={tab === key ? 'page' : undefined} onClick={() => navigate(key as Tab)}>{label}</button>)}</nav>
|
||||
<div className="sidebar-bottom"><a href="/" target="_blank" rel="noreferrer">View portfolio ↗</a><button type="button" onClick={async () => {
|
||||
if (dirty && !confirm('Discard changes and sign out?')) return;
|
||||
try { const response = await fetch('/api/auth/sign-out', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!response.ok) throw new Error(); router.replace('/admin/login'); router.refresh(); }
|
||||
catch { setNotice('Sign out failed. Try again.'); }
|
||||
}}>Sign out</button></div>
|
||||
</aside>
|
||||
<main className="admin-main"><header className="admin-heading"><div><p className="eyebrow">MAKE IT YOURS</p><h1>{labels[tab]}</h1><p className="muted">{tab === 'media' ? 'Images and documents for your portfolio.' : 'Changes appear on your website when you save.'}</p></div><span className="publish-badge">{dirty ? 'Unsaved changes' : 'Live website'}</span></header>
|
||||
{notice && !dirty && <p role="status" className="notice">{notice}</p>}
|
||||
{tab === 'media' ? <MediaLibrary uploads={uploads} onUpload={onUpload} onDelete={key => setUploads(previous => previous.filter(item => item.key !== key))} /> : <div className={choices.length || tab !== 'settings' ? 'editor-layout' : ''}>
|
||||
{tab !== 'settings' && <aside className="record-list" aria-label="Content items">{choices.map(item => <button type="button" key={item.id} aria-current={activeId === item.id ? 'true' : undefined} onClick={() => navigate(tab, item.id)}><strong>{item.title}</strong><small>{item.visible ? item.id : 'Hidden'}</small></button>)}
|
||||
{tab !== 'sections' && <button type="button" className="add-record" onClick={() => navigate(tab, 'new')}>+ Add {tab === 'languages' ? 'language' : 'item'}</button>}
|
||||
</aside>}
|
||||
<ContentEditor key={`${tab}-${activeId}-${version}`} tab={tab} selected={activeId} snapshot={snapshot} uploads={uploads} onUpload={onUpload} onDirty={setDirty} onSaved={async id => { await refresh(); setSelected(id); setNotice('Saved. Your changes are live.'); }} onDeleted={async () => { await refresh(); setSelected(''); setNotice('Deleted. Your website has been updated.'); }} />
|
||||
</div>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
type EditorProps = { tab: Exclude<Tab, 'media'>; selected: string; snapshot: ContentSnapshot; uploads: Upload[]; onUpload: (upload: Upload) => void; onDirty: (dirty: boolean) => void; onSaved: (id: string) => Promise<void>; onDeleted: () => Promise<void> };
|
||||
function ContentEditor({ tab, selected, snapshot, uploads, onUpload, onDirty, onSaved, onDeleted }: EditorProps) {
|
||||
const isEntry = entryKinds.includes(tab as EntryKind);
|
||||
const resource = isEntry ? 'entries' : tab;
|
||||
const original = tab === 'settings' ? snapshot.settings
|
||||
: tab === 'sections' ? snapshot.sections.find(item => item.key === selected)!
|
||||
: tab === 'languages' ? snapshot.languages.find(item => item.code === selected) ?? { code: '', name: '', direction: 'ltr', enabled: true, messages: {} }
|
||||
: snapshot.entries.find(item => item.id === selected) ?? { id: '', kind: tab, position: snapshot.entries.filter(item => item.kind === tab).length, visible: true, shared: entrySamples[tab as EntryKind], translations: {} };
|
||||
const [draft, setDraft] = useState(() => structuredClone(original) as unknown as Record<string, Json>);
|
||||
const [locale, setLocale] = useState(snapshot.settings.defaultLanguage);
|
||||
const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(original);
|
||||
useEffect(() => { onDirty(dirty); }, [dirty, onDirty]);
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (dirty) event.preventDefault(); };
|
||||
window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn);
|
||||
}, [dirty]);
|
||||
const fieldProps = { issues, uploads, onUpload, disabled: busy };
|
||||
const translations = (draft.translations ?? {}) as Record<string, Record<string, Json>>;
|
||||
const update = (key: string, value: Json) => setDraft(previous => ({ ...previous, [key]: value }));
|
||||
const contentSchema = isEntry ? entrySchemas[tab as EntryKind] : tab === 'sections' ? sectionSchemas[selected as SectionKey] : undefined;
|
||||
const usesShared = isEntry && locale === snapshot.settings.defaultLanguage && !translations[locale];
|
||||
const contentValue = isEntry ? { ...(draft.shared as object), ...(translations[snapshot.settings.defaultLanguage] ?? {}), ...(translations[locale] ?? {}) }
|
||||
: tab === 'sections' ? translations[locale] ?? translations[snapshot.settings.defaultLanguage] : {};
|
||||
const translationPath = usesShared ? 'shared' : `translations.${locale}`;
|
||||
return <form className="editor-panel" onSubmit={async event => {
|
||||
event.preventDefault(); setBusy(true); setError(''); setIssues([]);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/${resource}${selected === 'new' && isEntry ? '?create=1' : ''}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) { setError(result.error || 'Save failed'); setIssues(result.issues || []); return; }
|
||||
await onSaved(String(draft.id || draft.code || draft.key || ''));
|
||||
} catch (error) { setError((error as Error).message || 'Connection failed. Try again.'); } finally { setBusy(false); }
|
||||
}}>
|
||||
<div className="editor-toolbar"><h2>{selected === 'new' ? 'New item' : tab === 'settings' ? 'Site identity' : humanize(selected)}</h2><button className="primary" disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button></div>
|
||||
{error && <div role="alert" className="error-summary"><strong>{error}</strong>{issues.length > 0 && <ul>{issues.map(issue => <li key={issue.path}>{humanize(issue.path)}: {issue.message}</li>)}</ul>}</div>}
|
||||
{tab !== 'languages' && <div className="language-toolbar"><label htmlFor="content-language">Content language</label><select id="content-language" value={locale} onChange={event => setLocale(event.target.value)}>{snapshot.languages.map(item => <option key={item.code} value={item.code}>{item.name}{item.code === snapshot.settings.defaultLanguage ? ' (default)' : ''}</option>)}</select>
|
||||
{contentSchema && locale !== snapshot.settings.defaultLanguage && !translations[locale] && <p className="muted small">No translation yet. Showing default-language content.</p>}
|
||||
{contentSchema && locale !== snapshot.settings.defaultLanguage && !translations[locale] && <button type="button" className="secondary" onClick={() => update('translations', { ...translations, [locale]: asJson(contentValue) })}>Create translation from default</button>}
|
||||
</div>}
|
||||
{isEntry && <div className="entry-meta"><Field {...fieldProps} label="ID (used in the URL)" path="id" schema={{ type: 'string' }} value={draft.id} disabled={busy || selected !== 'new'} onChange={value => update('id', value)} /><Field {...fieldProps} label="Position" path="position" schema={{ type: 'integer', minimum: 0 }} value={draft.position} onChange={value => update('position', value)} /><Field {...fieldProps} label="Visible" path="visible" schema={{ type: 'boolean' }} value={draft.visible} onChange={value => update('visible', value)} /></div>}
|
||||
{tab === 'sections' && <Field {...fieldProps} label="Visible" path="visible" schema={{ type: 'boolean' }} value={draft.visible} onChange={value => update('visible', value)} />}
|
||||
{contentSchema && <Field {...fieldProps} label="Content" path={translationPath} schema={schemaFor(contentSchema)} value={asJson(contentValue)} onChange={value => usesShared ? update('shared', value) : update('translations', { ...translations, [locale]: value })} />}
|
||||
{tab === 'settings' && <>
|
||||
<Field {...fieldProps} label="General settings" schema={{ ...schemaFor(settingsSchema.omit({ metadata: true })), properties: { ...schemaFor(settingsSchema.omit({ metadata: true })).properties, defaultLanguage: { type: 'string', enum: snapshot.languages.filter(item => item.enabled).map(item => item.code) } } }} value={draft} onChange={value => setDraft(previous => ({ ...previous, ...(value as object) }))} />
|
||||
<Field {...fieldProps} label="Search engine metadata" path={`metadata.${locale}`} schema={schemaFor(settingsSchema.shape.metadata.valueType)} value={asJson((draft.metadata as Record<string, Json>)[locale] ?? (draft.metadata as Record<string, Json>)[snapshot.settings.defaultLanguage])} onChange={value => update('metadata', { ...(draft.metadata as object), [locale]: value })} />
|
||||
</>}
|
||||
{tab === 'languages' && <>
|
||||
<Field {...fieldProps} label="Language settings" schema={schemaFor(languageSchema.omit({ messages: true, code: true }))} value={draft} onChange={value => setDraft(previous => ({ ...previous, ...(value as object) }))} />
|
||||
<Field {...fieldProps} label="Language code (en, fa, de, ar, pt-br…)" path="code" schema={{ type: 'string' }} value={draft.code} disabled={busy || selected !== 'new'} onChange={value => update('code', value)} />
|
||||
<h3>Public interface labels</h3><p className="muted">Leave a label empty to use its built-in translation or English. Keep placeholders such as {'{title}'} unchanged.</p>
|
||||
{Object.keys(dictionaries.en).map(group => <details key={group} className="message-group"><summary>{humanize(group)}</summary>{Object.entries(messageDefaults).filter(([key]) => key.startsWith(`${group}.`)).map(([key, fallback]) => {
|
||||
const builtIn = flattenMessages(dictionaries[String(draft.code)] ?? dictionaries.en)[key] ?? fallback;
|
||||
return <div className="field" key={key}><label htmlFor={`message-${key}`}>{humanize(key.split('.').slice(1).join(' — '))}</label><textarea id={`message-${key}`} rows={2} dir="auto" placeholder={builtIn} value={String((draft.messages as Record<string, Json>)[key] ?? '')} onChange={event => update('messages', { ...(draft.messages as object), [key]: event.target.value })} /></div>;
|
||||
})}</details>)}
|
||||
</>}
|
||||
<div className="editor-footer"><span className="muted small">{dirty ? 'You have unsaved changes.' : 'All changes saved.'}</span>{isEntry && selected !== 'new' && <button type="button" className="danger" disabled={busy} onClick={async () => {
|
||||
if (!confirm('Delete this item from your portfolio? This cannot be undone.')) return;
|
||||
setBusy(true); setError('');
|
||||
try { const response = await fetch(`/api/admin/entries?id=${encodeURIComponent(selected)}`, { method: 'DELETE' }); const result = await response.json(); if (!response.ok) throw new Error(result.error); await onDeleted(); }
|
||||
catch (error) { setError((error as Error).message); } finally { setBusy(false); }
|
||||
}}>Delete item</button>}<button className="primary" disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button></div>
|
||||
</form>;
|
||||
}
|
||||
|
||||
function MediaLibrary({ uploads, onUpload, onDelete }: { uploads: Upload[]; onUpload: (upload: Upload) => void; onDelete: (key: string) => void }) {
|
||||
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
|
||||
return <section className="editor-panel"><div className="editor-toolbar"><h2>{uploads.length} files</h2><label className="upload-button">{busy ? 'Uploading…' : '+ Upload file'}<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" disabled={busy} onChange={async event => {
|
||||
const file = event.target.files?.[0]; if (!file) return; setBusy(true); setError('');
|
||||
try { onUpload(await uploadFile(file)); } catch (error) { setError((error as Error).message); } finally { setBusy(false); event.target.value = ''; }
|
||||
}} /></label></div><p className="muted">JPEG, PNG, WebP, or PDF. Up to 10 MB per file. Images are optimized on upload.</p>
|
||||
{error && <p role="alert" className="field-error">{error}</p>}
|
||||
{uploads.length === 0 && <div className="empty-state"><h3>Your media library is ready.</h3><p>Upload an avatar, project image, or certificate to get started.</p></div>}
|
||||
<ul className="media-list">{uploads.map(item => <li key={item.key}><div><a href={item.url} target="_blank" rel="noreferrer">{item.originalName}</a><small>{item.mime} · {Math.ceil(item.size / 1024)} KB</small></div><button type="button" className="danger" disabled={busy} onClick={async () => {
|
||||
if (!confirm('Permanently delete this upload?')) return;
|
||||
setBusy(true); setError('');
|
||||
try { const response = await fetch(`/api/admin/media?key=${item.key}`, { method: 'DELETE' }); const result = await response.json(); if (!response.ok) throw new Error(result.error); onDelete(item.key); }
|
||||
catch (error) { setError((error as Error).message); } finally { setBusy(false); }
|
||||
}}>Delete</button></li>)}</ul>
|
||||
</section>;
|
||||
}
|
||||
83
src/components/admin/Fields.tsx
Normal file
83
src/components/admin/Fields.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
||||
export type FormSchema = { type?: string; properties?: Record<string, FormSchema>; items?: FormSchema; enum?: string[]; anyOf?: FormSchema[]; minLength?: number; maxLength?: number; minimum?: number; maximum?: number; maxItems?: number; required?: string[] };
|
||||
export type Upload = { key: string; url: string; originalName: string; mime: string; width: number | null; height: number | null; size: number };
|
||||
export type Issue = { path: string; message: string };
|
||||
export const humanize = (text: string) => text.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/^./, char => char.toUpperCase());
|
||||
export function emptyValue(schema: FormSchema): Json {
|
||||
if (schema.anyOf) return emptyValue(schema.anyOf[0]);
|
||||
if (schema.enum) return schema.enum[0];
|
||||
if (schema.type === 'object') return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, value]) => [key, emptyValue(value)]));
|
||||
if (schema.type === 'array') return [];
|
||||
if (schema.type === 'boolean') return false;
|
||||
if (schema.type === 'number' || schema.type === 'integer') return schema.minimum ?? 0;
|
||||
return '';
|
||||
}
|
||||
export async function uploadFile(file: File) {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch('/api/admin/media', { method: 'POST', body: form });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Upload failed');
|
||||
return result as Upload;
|
||||
}
|
||||
type Props = { label: string; path?: string; schema: FormSchema; value: Json; onChange: (value: Json) => void; issues?: Issue[]; uploads: Upload[]; onUpload: (upload: Upload) => void; disabled?: boolean };
|
||||
export function Field({ label, path = '', schema: inputSchema, value, onChange, issues = [], uploads, onUpload, disabled }: Props) {
|
||||
const id = useId();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const issue = issues.find(issue => issue.path === path);
|
||||
const common = { id, disabled, 'aria-invalid': Boolean(issue), 'aria-describedby': issue ? `${id}-error` : undefined };
|
||||
const schema = inputSchema.anyOf?.[0] ?? inputSchema;
|
||||
const nested = { issues, uploads, onUpload, disabled };
|
||||
const record = (value && typeof value === 'object' && !Array.isArray(value) ? value : {}) as Record<string, Json>;
|
||||
if (schema.type === 'object' && schema.properties?.src && schema.properties?.width) {
|
||||
return <fieldset className="image-field"><legend>{label}</legend>
|
||||
{typeof record.src === 'string' && record.src && <Image src={record.src} width={160} height={100} alt={`${label} preview`} unoptimized />}
|
||||
<label htmlFor={id}>Choose image</label>
|
||||
<select {...common} value={String(record.src || '')} onChange={event => {
|
||||
const media = uploads.find(item => item.url === event.target.value);
|
||||
if (media) onChange({ src: media.url, width: media.width!, height: media.height! });
|
||||
}}>
|
||||
<option value={String(record.src || '')}>Current image</option>
|
||||
{uploads.filter(item => item.mime.startsWith('image/')).map(item => <option key={item.key} value={item.url}>{item.originalName}</option>)}
|
||||
</select>
|
||||
<label className="upload-button">{uploading ? 'Uploading…' : 'Upload image'}<input type="file" accept="image/jpeg,image/png,image/webp" disabled={disabled || uploading} onChange={async event => {
|
||||
const file = event.target.files?.[0]; if (!file) return;
|
||||
setUploading(true); setUploadError('');
|
||||
try { const uploaded = await uploadFile(file); onUpload(uploaded); onChange({ src: uploaded.url, width: uploaded.width!, height: uploaded.height! }); }
|
||||
catch (error) { setUploadError((error as Error).message); } finally { setUploading(false); }
|
||||
}} /></label>
|
||||
{(issue || uploadError) && <p role="alert" className="field-error">{issue?.message || uploadError}</p>}
|
||||
{issues.filter(item => item.path.startsWith(`${path}.`)).map(item => <p key={item.path} role="alert" className="field-error">{item.message}</p>)}
|
||||
</fieldset>;
|
||||
}
|
||||
if (schema.type === 'object') return <fieldset className="object-field"><legend>{label}</legend>
|
||||
{Object.entries(schema.properties ?? {}).map(([key, property]) => <Field key={key} {...nested} label={humanize(key)} path={path ? `${path}.${key}` : key} schema={property} value={record[key] ?? emptyValue(property)} onChange={next => onChange({ ...record, [key]: next })} />)}
|
||||
</fieldset>;
|
||||
if (schema.type === 'array') {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
const move = (index: number, delta: number) => { const next = [...items]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; onChange(next); };
|
||||
return <fieldset className="array-field"><legend>{label} <span className="count">{items.length}</span></legend>
|
||||
{items.map((item, index) => <div className="array-item" key={index}>
|
||||
<div className="array-actions"><span>{index + 1}</span><button type="button" disabled={disabled || index === 0} onClick={() => move(index, -1)} aria-label={`Move ${label} item ${index + 1} up`}>↑</button><button type="button" disabled={disabled || index === items.length - 1} onClick={() => move(index, 1)} aria-label={`Move ${label} item ${index + 1} down`}>↓</button><button type="button" disabled={disabled} onClick={() => { if (confirm('Remove this item?')) onChange(items.filter((_, i) => i !== index)); }}>Remove</button></div>
|
||||
<Field {...nested} label={`${label} item ${index + 1}`} path={`${path}.${index}`} schema={schema.items!} value={item} onChange={next => onChange(items.map((previous, i) => i === index ? next : previous))} />
|
||||
</div>)}
|
||||
<button type="button" className="secondary" disabled={disabled || items.length >= (schema.maxItems ?? 200)} onClick={() => onChange([...items, emptyValue(schema.items!)])}>+ Add {label.toLowerCase()} item</button>
|
||||
{issue && <p id={`${id}-error`} className="field-error">{issue.message}</p>}
|
||||
</fieldset>;
|
||||
}
|
||||
const long = /description|summary|introduction|challenge|subtitle|ownership|outcomes|lessons|constraints|bullets/i.test(label) || String(value).length > 160;
|
||||
return <div className={`field ${schema.type === 'boolean' ? 'checkbox-field' : ''}`}>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{schema.enum ? <select {...common} value={String(value)} onChange={event => onChange(event.target.value)}>{schema.enum.map(option => <option key={option} value={option}>{humanize(option)}</option>)}</select>
|
||||
: schema.type === 'boolean' ? <input {...common} type="checkbox" checked={Boolean(value)} onChange={event => onChange(event.target.checked)} />
|
||||
: schema.type === 'number' || schema.type === 'integer' ? <input {...common} type="number" min={schema.minimum} max={schema.maximum} value={Number(value)} onChange={event => onChange(Number(event.target.value))} />
|
||||
: long ? <textarea {...common} dir="auto" rows={4} maxLength={schema.maxLength} value={String(value ?? '')} onChange={event => onChange(event.target.value)} />
|
||||
: <input {...common} dir="auto" type="text" maxLength={schema.maxLength} value={String(value ?? '')} onChange={event => onChange(event.target.value)} />}
|
||||
{issue && <p id={`${id}-error`} className="field-error">{issue.message}</p>}
|
||||
{schema.type === 'string' && /url|link/i.test(label) && uploads.some(item => item.mime === 'application/pdf') && <select aria-label={`Choose attachment for ${label}`} value="" disabled={disabled} onChange={event => onChange(event.target.value)}><option value="">Choose an uploaded attachment…</option>{uploads.filter(item => item.mime === 'application/pdf').map(item => <option key={item.key} value={item.url}>{item.originalName}</option>)}</select>}
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user